@agentshouse/kit 0.2.1-ce145e9aadbf6 → 0.2.1-cf6326dfa5c3e

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 (103) hide show
  1. package/README.md +32 -31
  2. package/dist/acp.js +46 -103
  3. package/dist/agents.js +151 -41
  4. package/dist/api.js +34 -2
  5. package/dist/app-git.js +48 -0
  6. package/dist/bootstrap.json +4 -4
  7. package/dist/bridge.js +36 -57
  8. package/dist/chatgpt.js +32 -0
  9. package/dist/clis.js +126 -69
  10. package/dist/codex-main.js +58 -0
  11. package/dist/conversations.js +457 -277
  12. package/dist/copies.js +198 -124
  13. package/dist/end.js +23 -0
  14. package/dist/enrol-main.js +7 -1
  15. package/dist/events.js +525 -0
  16. package/dist/files.js +22 -2
  17. package/dist/git.js +10 -8
  18. package/dist/home.js +96 -7
  19. package/dist/house-cli.js +291 -0
  20. package/dist/house-main.js +7 -159
  21. package/dist/inputs.js +63 -0
  22. package/dist/instructions.js +10 -32
  23. package/dist/kit-cli.js +68 -0
  24. package/dist/kit-main.js +8 -19
  25. package/dist/login.js +118 -103
  26. package/dist/logout.js +14 -13
  27. package/dist/parts.js +82 -0
  28. package/dist/placement.js +30 -0
  29. package/dist/refusals.js +12 -0
  30. package/dist/relay.js +12 -2
  31. package/dist/resident.js +91 -35
  32. package/dist/scope.js +203 -0
  33. package/dist/secret-input.js +3 -2
  34. package/dist/service-main.js +9 -0
  35. package/dist/service.js +55 -0
  36. package/dist/shell.js +3 -0
  37. package/dist/sign-in.js +34 -28
  38. package/dist/skills.js +81 -12
  39. package/dist/stream.js +87 -11
  40. package/dist/transcription.js +57 -9
  41. package/dist/translate.js +2 -8
  42. package/dist/update.js +50 -29
  43. package/dist/wire.js +6 -0
  44. package/dist/work.js +346 -0
  45. package/how-we-work.md +13 -3
  46. package/package.json +3 -2
  47. package/skills/ask-matt/SKILL.md +17 -11
  48. package/skills/ask-matt/agents/openai.yaml +1 -0
  49. package/skills/code-review/SKILL.md +5 -5
  50. package/skills/codebase-design/DESIGN-IT-TWICE.md +1 -1
  51. package/skills/codebase-design/SKILL.md +0 -2
  52. package/skills/diagnosing-bugs/SKILL.md +2 -4
  53. package/skills/domain-modeling/{CONTEXT-FORMAT.md → GLOSSARY-FORMAT.md} +10 -10
  54. package/skills/domain-modeling/SKILL.md +12 -14
  55. package/skills/grill-me/SKILL.md +1 -2
  56. package/skills/grill-me/agents/openai.yaml +1 -0
  57. package/skills/grill-with-docs/SKILL.md +1 -2
  58. package/skills/grill-with-docs/agents/openai.yaml +1 -0
  59. package/skills/grilling/SKILL.md +2 -2
  60. package/skills/handoff/SKILL.md +2 -3
  61. package/skills/handoff/agents/openai.yaml +1 -0
  62. package/skills/implement/SKILL.md +5 -4
  63. package/skills/implement/agents/openai.yaml +1 -0
  64. package/skills/implement-spec/SKILL.md +40 -0
  65. package/skills/implement-spec/agents/openai.yaml +5 -0
  66. package/skills/improve-codebase-architecture/SKILL.md +6 -7
  67. package/skills/improve-codebase-architecture/agents/openai.yaml +1 -0
  68. package/skills/pr/CREDITS.md +3 -0
  69. package/skills/pr/LICENSE +21 -0
  70. package/skills/pr/SKILL.md +170 -0
  71. package/skills/pr/agents/openai.yaml +3 -0
  72. package/skills/prototype/SKILL.md +0 -2
  73. package/skills/research/SKILL.md +0 -2
  74. package/skills/retro/LICENSE +21 -0
  75. package/skills/retro/SKILL.md +44 -0
  76. package/skills/retro/agents/openai.yaml +5 -0
  77. package/skills/tdd/SKILL.md +2 -4
  78. package/skills/teach/SKILL.md +3 -4
  79. package/skills/teach/agents/openai.yaml +1 -0
  80. package/skills/to-questionnaire/SKILL.md +1 -2
  81. package/skills/to-questionnaire/agents/openai.yaml +1 -0
  82. package/skills/to-spec/SKILL.md +1 -2
  83. package/skills/to-spec/agents/openai.yaml +1 -0
  84. package/skills/to-tickets/SKILL.md +3 -4
  85. package/skills/to-tickets/agents/openai.yaml +1 -0
  86. package/skills/triage/AGENT-BRIEF.md +207 -0
  87. package/skills/triage/LICENSE +21 -0
  88. package/skills/triage/OUT-OF-SCOPE.md +105 -0
  89. package/skills/triage/SKILL.md +112 -0
  90. package/skills/triage/agents/openai.yaml +5 -0
  91. package/skills/wait-what/SKILL.md +2 -3
  92. package/skills/wait-what/agents/openai.yaml +1 -0
  93. package/skills/wayfinder/SKILL.md +5 -6
  94. package/skills/wayfinder/agents/openai.yaml +1 -0
  95. package/skills/wizard/LICENSE +21 -0
  96. package/skills/wizard/SKILL.md +44 -0
  97. package/skills/wizard/agents/openai.yaml +3 -0
  98. package/skills/wizard/template.sh +223 -0
  99. package/skills/writing-for-agents/SKILL.md +0 -2
  100. package/dist/blocks.js +0 -51
  101. package/skills/resolving-merge-conflicts/SKILL.md +0 -16
  102. package/skills/resolving-merge-conflicts/agents/openai.yaml +0 -3
  103. /package/skills/{resolving-merge-conflicts → implement-spec}/LICENSE +0 -0
package/dist/api.js CHANGED
@@ -1,5 +1,9 @@
1
+ import { setTimeout as delay } from 'node:timers/promises';
2
+ import { signInAgain, updateRequired } from './end.js';
1
3
  import { readEnrolment } from './home.js';
4
+ import { retryable } from './refusals.js';
2
5
  import { relayed } from './relay.js';
6
+ import { KIT_WIRE, KIT_WIRE_HEADER, tooOld } from './wire.js';
3
7
  export class HouseRefusal extends Error {
4
8
  status;
5
9
  text;
@@ -9,27 +13,54 @@ export class HouseRefusal extends Error {
9
13
  this.text = text;
10
14
  }
11
15
  }
16
+ export function finalRefusal(error) {
17
+ return error instanceof HouseRefusal && retryable(error.text) === false;
18
+ }
12
19
  export function retryDelay(attempt) {
20
+ // Full jitter, doubling from one second to a 30-second cap, spreads the Kits one outage dropped and retries within half a minute of House's return.
13
21
  return Math.random() * Math.min(30_000, 1000 * 2 ** attempt);
14
22
  }
23
+ export async function retrying(sent, signal, pause = retryDelay) {
24
+ for (let attempt = 0;; attempt++) {
25
+ try {
26
+ return await sent();
27
+ }
28
+ catch (error) {
29
+ if (!(error instanceof HouseRefusal) || retryable(error.text) !== true)
30
+ throw error;
31
+ }
32
+ // A request House refused as retryable is sent again after the caller's pause, the jittered curve unless it names its own.
33
+ await delay(pause(attempt), undefined, { signal });
34
+ }
35
+ }
15
36
  export async function post(house, path, body, credential, signal) {
16
37
  const answer = await fetch(relayed(new URL(path, house)), {
17
38
  method: 'POST',
18
39
  headers: {
19
40
  'content-type': 'application/json',
41
+ [KIT_WIRE_HEADER]: String(KIT_WIRE),
20
42
  ...(credential === undefined ? {} : { authorization: `Bearer ${credential}` }),
21
43
  },
22
44
  body: JSON.stringify(body),
23
45
  signal,
24
46
  });
25
47
  const text = await answer.text();
48
+ if (!answer.ok && tooOld(text))
49
+ updateRequired();
26
50
  if (!answer.ok)
27
51
  throw new HouseRefusal(path, answer.status, text);
28
52
  return JSON.parse(text);
29
53
  }
30
54
  async function enrolledPost(path, body, signal) {
31
55
  const { house, credential } = (await readEnrolment());
32
- return post(house, path, body, credential, signal);
56
+ try {
57
+ return await post(house, path, body, credential, signal);
58
+ }
59
+ catch (error) {
60
+ if (error instanceof HouseRefusal && error.status === 401)
61
+ await signInAgain(credential);
62
+ throw error;
63
+ }
33
64
  }
34
65
  export const house = {
35
66
  post: enrolledPost,
@@ -39,9 +70,10 @@ export const house = {
39
70
  return await enrolledPost(path, body);
40
71
  }
41
72
  catch (error) {
42
- if (error instanceof HouseRefusal && error.status < 500)
73
+ if (finalRefusal(error))
43
74
  throw error;
44
75
  process.stderr.write(`kit: ${path} did not reach House: ${error.message}\n`);
76
+ // A delivery House did not take is sent again on the jittered curve for as long as it is still current.
45
77
  await new Promise((resolve) => setTimeout(resolve, retryDelay(attempt)));
46
78
  if (!current())
47
79
  throw error;
@@ -0,0 +1,48 @@
1
+ import { run } from './git.js';
2
+ import { kitHome, readOwnAgent } from './home.js';
3
+ const HELPER = '!kit git-credential';
4
+ function section(house) {
5
+ return `credential.${new URL('/app/', house).href}`;
6
+ }
7
+ async function configured(args) {
8
+ try {
9
+ return await run(kitHome(), ['config', '--global', ...args]);
10
+ }
11
+ catch (error) {
12
+ if (error.code === 'ENOENT')
13
+ return null;
14
+ throw error;
15
+ }
16
+ }
17
+ export async function placeAppGit(ownAgent) {
18
+ if (ownAgent === null)
19
+ return;
20
+ await configured(['--remove-section', section(ownAgent.house)]);
21
+ for (const value of ['', HELPER]) {
22
+ const ran = await configured(['--add', `${section(ownAgent.house)}.helper`, value]);
23
+ if (ran === null)
24
+ return;
25
+ if (ran.status !== 0)
26
+ throw new Error(`git config failed: ${ran.stderr.trim()}`);
27
+ }
28
+ }
29
+ export async function answerGitCredential(operation) {
30
+ let asked = '';
31
+ for await (const chunk of process.stdin)
32
+ asked += String(chunk);
33
+ if (operation !== 'get')
34
+ return;
35
+ const attributes = new Map(asked
36
+ .split('\n')
37
+ .filter((line) => line.includes('='))
38
+ .map((line) => [line.slice(0, line.indexOf('=')), line.slice(line.indexOf('=') + 1)]));
39
+ const ownAgent = await readOwnAgent();
40
+ if (ownAgent === null) {
41
+ process.stderr.write(`kit: Kit home ${kitHome()} is not signed in; run kit login\n`);
42
+ return;
43
+ }
44
+ const house = new URL(ownAgent.house);
45
+ if (attributes.get('protocol') !== house.protocol.slice(0, -1) || attributes.get('host') !== house.host)
46
+ return;
47
+ process.stdout.write(`username=house\npassword=${ownAgent.credential}\n`);
48
+ }
@@ -1,23 +1,23 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
- "version": "0.2.1-ce145e9aadbf6",
3
+ "version": "0.2.1-cf6326dfa5c3e",
4
4
  "hosts": {
5
5
  "macos": {
6
6
  "asset": {
7
7
  "name": "connect-linux.sh",
8
- "url": "https://github.com/agentshouse/kit/releases/download/v0.2.1-ce145e9aadbf6/connect-linux.sh"
8
+ "url": "https://github.com/agentshouse/kit/releases/download/v0.2.1-cf6326dfa5c3e/connect-linux.sh"
9
9
  }
10
10
  },
11
11
  "linux": {
12
12
  "asset": {
13
13
  "name": "connect-linux.sh",
14
- "url": "https://github.com/agentshouse/kit/releases/download/v0.2.1-ce145e9aadbf6/connect-linux.sh"
14
+ "url": "https://github.com/agentshouse/kit/releases/download/v0.2.1-cf6326dfa5c3e/connect-linux.sh"
15
15
  }
16
16
  },
17
17
  "windows": {
18
18
  "asset": {
19
19
  "name": "connect-windows.ps1",
20
- "url": "https://github.com/agentshouse/kit/releases/download/v0.2.1-ce145e9aadbf6/connect-windows.ps1"
20
+ "url": "https://github.com/agentshouse/kit/releases/download/v0.2.1-cf6326dfa5c3e/connect-windows.ps1"
21
21
  }
22
22
  }
23
23
  }
package/dist/bridge.js CHANGED
@@ -1,21 +1,19 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import { once } from 'node:events';
3
3
  import { setTimeout as delay } from 'node:timers/promises';
4
- import { mkdir } from 'node:fs/promises';
5
4
  import { createServer, request as httpRequest } from 'node:http';
6
5
  import { request as httpsRequest } from 'node:https';
7
6
  import { delimiter, dirname, join } from 'node:path';
8
7
  import { parse } from 'yaml';
9
- import { HouseRefusal, retryDelay } from './api.js';
8
+ import { finalRefusal, HouseRefusal, retryDelay, retrying } from './api.js';
10
9
  import { loginPath } from './clis.js';
11
10
  import { changedPaths, } from './copies.js';
12
- import { kitHome, readEnrolment } from './home.js';
11
+ import { kitHome, readEnrolment, socketDirectory } from './home.js';
13
12
  import { callHouse, mcpBody, rpc, UNREACHABLE } from './mcp.js';
14
- import { operationId, uuidOf } from './operation.js';
15
- import { refusalOf } from './refusals.js';
13
+ import { operationId } from './operation.js';
14
+ import { refusalOf, retryable } from './refusals.js';
16
15
  import { relayed } from './relay.js';
17
16
  const MCP_REQUEST_BYTES = 4 * 1024 * 1024;
18
- const ATTACHMENTS = '/kit/attachments/upload';
19
17
  export async function received(request) {
20
18
  let text = '';
21
19
  request.setEncoding('utf8');
@@ -40,8 +38,6 @@ function refusalIn(text, status) {
40
38
  return refusalOf(text) ?? { code: '', text: text === '' ? `House refused it with ${status}` : text, conflicts: [] };
41
39
  }
42
40
  function answered(forwarded) {
43
- if (forwarded.status >= 500)
44
- throw new HouseRefusal('/', forwarded.status, forwarded.text);
45
41
  if (forwarded.status !== 200)
46
42
  return { refused: refusalIn(forwarded.text, forwarded.status) };
47
43
  const answer = JSON.parse(forwarded.text);
@@ -65,16 +61,11 @@ function pendingSeconds(forwarded) {
65
61
  function resendAfter(forwarded, attempt) {
66
62
  if (forwarded === null)
67
63
  return retryDelay(attempt);
68
- if (forwarded.status >= 500)
69
- return refusalOf(forwarded.text) === null ? retryDelay(attempt) : null;
64
+ if (forwarded.status !== 200)
65
+ return retryable(forwarded.text) === false ? null : retryDelay(attempt);
70
66
  const seconds = pendingSeconds(forwarded);
71
67
  return seconds === null ? null : seconds * 1000;
72
68
  }
73
- function versioned(conversationId, text) {
74
- const file = JSON.parse(text);
75
- const identity = JSON.stringify([conversationId, file.room_ref ?? null, file.name, file.sha256]);
76
- return JSON.stringify({ ...file, version: uuidOf(createHash('sha256').update(identity).digest(), 8) });
77
- }
78
69
  function editAnswer(forwarded) {
79
70
  const answer = answered(forwarded);
80
71
  if ('refused' in answer)
@@ -90,7 +81,7 @@ function editAnswer(forwarded) {
90
81
  export async function openBridge(house, copies, conversationId, signal) {
91
82
  const path = [join(kitHome(), 'shim'), dirname(process.execPath), await loginPath()].join(delimiter);
92
83
  const origin = (await readEnrolment()).house;
93
- const { credential } = await house.post(`/kit/conversations/${conversationId}/credential`, {}, signal);
84
+ const { credential } = await retrying(() => house.post(`/kit/conversations/${conversationId}/credential`, {}, signal), signal);
94
85
  const closed = new AbortController();
95
86
  const sent = (message, operation) => callHouse(origin, credential, message, operation, closed.signal);
96
87
  const send = async (message, operation) => {
@@ -105,6 +96,7 @@ export async function openBridge(house, copies, conversationId, signal) {
105
96
  const after = resendAfter(forwarded, attempt);
106
97
  if (after === null)
107
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.
108
100
  await delay(after, undefined, { signal: closed.signal });
109
101
  }
110
102
  };
@@ -119,46 +111,32 @@ export async function openBridge(house, copies, conversationId, signal) {
119
111
  if ('refused' in prepared)
120
112
  return prepared;
121
113
  const { transfer } = parse(prepared.text);
122
- const posted = await fetch(relayed(transfer.url), {
123
- method: 'POST',
124
- headers: { 'content-type': 'text/plain; charset=utf-8' },
125
- body: Buffer.concat([Buffer.from(`${transfer.operation}\n`), bytes]),
126
- signal: closed.signal,
127
- });
128
- const text = await posted.text();
129
- if (posted.status >= 500)
130
- throw new HouseRefusal('/', posted.status, text);
131
- if (!posted.ok)
132
- return { refused: refusalIn(text, posted.status) };
133
- const held = JSON.parse(text).transfer;
134
- return { url: held.url, operation: held.operation, bytes: bytes.length, sha256 };
135
- };
136
- let mutations = null;
137
- const writes = async (tool) => {
138
- mutations ??= house
139
- .post('/kit/tools/mutations', {}, closed.signal)
140
- .then(({ tools }) => new Set(tools.filter((listed) => listed.writes).map((listed) => listed.name)));
114
+ let text;
141
115
  try {
142
- return (await mutations).has(tool);
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);
143
128
  }
144
129
  catch (error) {
145
- mutations = null;
146
- throw error;
130
+ if (!finalRefusal(error))
131
+ throw error;
132
+ return { refused: refusalIn(error.text, error.status) };
147
133
  }
134
+ const held = JSON.parse(text).transfer;
135
+ return { url: held.url, operation: held.operation, bytes: bytes.length, sha256 };
148
136
  };
149
- const forward = async (path, text) => {
150
- if (path === '/') {
151
- const message = JSON.parse(text);
152
- const tool = message.method === 'tools/call' ? String(message.params.name) : null;
153
- return send(message, tool !== null && (await writes(tool)) ? operationId() : null);
154
- }
155
- const answer = await fetch(relayed(new URL(path, origin)), {
156
- method: 'POST',
157
- headers: { 'content-type': 'application/json', authorization: `Bearer ${credential}` },
158
- body: versioned(conversationId, text),
159
- signal: closed.signal,
160
- });
161
- 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);
162
140
  };
163
141
  const caller = {
164
142
  key: `conversation:${conversationId}`,
@@ -180,15 +158,15 @@ export async function openBridge(house, copies, conversationId, signal) {
180
158
  const { cwd, commit } = JSON.parse(text);
181
159
  return { status: 200, text: JSON.stringify(await copies.push(cwd, commit, caller)) };
182
160
  }
161
+ if (path === '/git/fetch')
162
+ return { status: 200, text: JSON.stringify(await copies.fetch()) };
183
163
  if (path === '/git/observed') {
184
164
  await copies.observed(JSON.parse(text));
185
165
  return { status: 200, text: '{}' };
186
166
  }
187
- return path === '/' || path === ATTACHMENTS ? forward(path, text) : { status: 404, text: '' };
167
+ return path === '/' ? forward(text) : { status: 404, text: '' };
188
168
  };
189
- const sockets = join(kitHome(), 'bridges');
190
- await mkdir(sockets, { recursive: true, mode: 0o700 });
191
- const socketPath = join(sockets, `${randomUUID()}.sock`);
169
+ const socketPath = join(socketDirectory(), `${randomUUID()}.sock`);
192
170
  const bridge = createServer(async (request, response) => {
193
171
  try {
194
172
  const path = request.url ?? '';
@@ -204,7 +182,8 @@ export async function openBridge(house, copies, conversationId, signal) {
204
182
  response.writeHead(error.status, { 'content-type': 'application/json' }).end(error.text);
205
183
  return;
206
184
  }
207
- response.writeHead(502).end(request.url === '/git/push' && error instanceof Error ? error.message : UNREACHABLE);
185
+ const verb = request.url === '/git/push' || request.url === '/git/fetch';
186
+ response.writeHead(502).end(verb && error instanceof Error ? error.message : UNREACHABLE);
208
187
  }
209
188
  });
210
189
  bridge.listen(socketPath);
@@ -231,7 +210,7 @@ export async function openBridge(house, copies, conversationId, signal) {
231
210
  GIT_CONFIG_VALUE_0: app,
232
211
  },
233
212
  tool: async (name, args) => {
234
- const forwarded = await forward('/', JSON.stringify(call(name, args)));
213
+ const forwarded = await forward(JSON.stringify(call(name, args)));
235
214
  if (forwarded.status !== 200)
236
215
  throw new Error(`House refused ${name} with ${forwarded.status}: ${forwarded.text}`);
237
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
+ }
package/dist/clis.js CHANGED
@@ -1,57 +1,94 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { readFileSync } from 'node:fs';
3
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { constants, readFileSync } from 'node:fs';
4
+ import { access, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
4
5
  import { homedir, userInfo } from 'node:os';
5
- import { join } from 'node:path';
6
+ import { delimiter, dirname, isAbsolute, join } from 'node:path';
6
7
  import { kitHome } from './home.js';
7
- const RUNNING_JOB = new Set(['running', 'paused']);
8
- async function allowHouseInCodex() {
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 [];
46
+ }
47
+ }
48
+ export async function allowHouseInCodex(path) {
49
+ const candidates = [...new Set(path.split(delimiter).filter((directory) => isAbsolute(directory)))].map((directory) => join(directory, 'house'));
9
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
+ }
10
57
  await mkdir(rules, { recursive: true });
11
- await writeFile(join(rules, 'house.rules'), 'prefix_rule(pattern = ["house"], decision = "allow")\n');
58
+ const temporary = `${file}.${randomUUID()}`;
59
+ await writeFile(temporary, houseRule(houses));
60
+ await rename(temporary, file);
12
61
  }
13
62
  async function nothing() { }
14
- function threadStatus({ method, params }) {
15
- if (method !== 'session/update' || params?.update?.sessionUpdate !== 'session_info_update')
16
- return undefined;
17
- return params.update._meta?.codex?.threadStatus?.type;
18
- }
19
- function asyncTask({ method, params }) {
20
- const update = params?.update;
21
- if (method !== 'session/update' || update?.asyncTaskId === undefined)
22
- return null;
23
- if (update.sessionUpdate === 'async_task_spawned')
24
- return { id: update.asyncTaskId, running: true };
25
- if (update.sessionUpdate === 'async_task_state_update' && !RUNNING_JOB.has(update.state ?? '')) {
26
- return { id: update.asyncTaskId, running: false };
27
- }
28
- return null;
63
+ function answered(error) {
64
+ return typeof error === 'object' && error !== null ? error : {};
29
65
  }
30
- function airPhase({ _meta }) {
31
- const phase = _meta?.jetbrains?.air?.phase;
32
- if (phase === 'commentary')
33
- return 'note';
34
- if (phase === 'final_answer')
35
- return 'answer';
36
- return null;
66
+ function bridgeInClaudeSandbox(bridge) {
67
+ return process.platform === 'darwin' ? { allowUnixSockets: [bridge] } : { allowAllUnixSockets: true };
37
68
  }
38
69
  export const CLIS = {
39
70
  'codex-acp': {
40
71
  bin: 'codex',
41
72
  minimum: '0.159.1',
42
73
  install: 'curl -fsSL https://chatgpt.com/codex/install.sh | sh',
43
- adapter: { package: '@agentclientprotocol/codex-acp', bin: 'codex-acp', executable: 'CODEX_PATH', env: {} },
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
+ },
44
80
  args: [],
45
81
  fullAccess: 'agent-full-access',
46
- sessionMeta: {},
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,
47
88
  allowHouse: allowHouseInCodex,
48
89
  signedIn: { command: ['login', 'status'] },
49
90
  login: { args: ['login', '--device-auth'], code: 'show' },
50
- queues: false,
51
- turnStarted: (notice) => threadStatus(notice) === 'active',
52
- turnEnded: (notice) => threadStatus(notice) === 'idle',
53
- job: asyncTask,
54
- phase: airPhase,
91
+ air: ['asyncTasks', 'nativeSubagentSessions'],
55
92
  },
56
93
  'claude-agent-acp': {
57
94
  bin: 'claude',
@@ -65,47 +102,47 @@ export const CLIS = {
65
102
  },
66
103
  args: [],
67
104
  fullAccess: 'bypassPermissions',
68
- sessionMeta: { claudeCode: { options: { allowedTools: ['Bash(house:*)'] } } },
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,
69
118
  allowHouse: nothing,
70
119
  signedIn: { command: ['auth', 'status'] },
71
120
  login: { args: ['auth', 'login', '--claudeai'], code: 'collect', rejected: 'Invalid code' },
72
- queues: false,
73
- turnStarted: () => false,
74
- turnEnded: ({ method, params }) => method === 'session/update' &&
75
- params?.update?.sessionUpdate === 'usage_update' &&
76
- params.update._meta?.['_claude/origin'] !== undefined,
77
- job: asyncTask,
78
- phase: () => null,
121
+ air: ['asyncTasks', 'sessionFailure'],
79
122
  },
80
123
  'grok-build': {
81
124
  bin: 'grok',
82
125
  minimum: '1.0.46',
83
126
  install: 'curl -fsSL https://x.ai/cli/install.sh | bash',
84
127
  adapter: null,
85
- args: ['agent', '--always-approve', '--no-leader', 'stdio'],
128
+ args: ['--sandbox', 'off', 'agent', '--always-approve', '--no-leader', 'stdio'],
86
129
  fullAccess: null,
87
- sessionMeta: {},
130
+ sessionMeta: () => ({}),
131
+ unknownSession: (error) => answered(error).data?.code === 'FS_NOT_FOUND',
132
+ renames: false,
88
133
  allowHouse: nothing,
89
134
  signedIn: { initializeMeta: 'defaultAuthMethodId' },
90
135
  login: { args: ['login', '--device-auth'], code: 'show' },
91
- queues: true,
92
- turnStarted: ({ method, params }) => method === '_x.ai/queue/changed' && params?.runningPromptId !== undefined,
93
- turnEnded: ({ method, params }) => method === '_x.ai/session_notification' && params?.update?.sessionUpdate === 'turn_completed',
94
- job: ({ method, params }) => {
95
- if (method === '_x.ai/task_backgrounded')
96
- return { id: params.update.task_id, running: true };
97
- if (method === '_x.ai/task_completed')
98
- return { id: params.update.task_snapshot.task_id, running: false };
99
- return null;
100
- },
101
- phase: () => null,
136
+ air: ['asyncTasks'],
102
137
  },
103
138
  };
104
139
  const KIT_PACKAGE = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
105
140
  export const KIT_VERSION = KIT_PACKAGE.version;
106
141
  const PROXY = /^https?_proxy$/i;
107
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.
108
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.
109
146
  const READ_MS = 30_000;
110
147
  const CAUSE_CHARACTERS = 4000;
111
148
  function prefix(kind) {
@@ -133,8 +170,22 @@ export function run(command, args, timeoutMs, detached = false, signal) {
133
170
  timeout: timeoutMs,
134
171
  env: withoutProxy(),
135
172
  detached,
136
- signal,
173
+ signal: detached ? undefined : signal,
137
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
+ };
138
189
  let stdout = '';
139
190
  let output = '';
140
191
  child.stdout.setEncoding('utf8').on('data', (chunk) => {
@@ -144,25 +195,25 @@ export function run(command, args, timeoutMs, detached = false, signal) {
144
195
  child.stderr.setEncoding('utf8').on('data', (chunk) => {
145
196
  output += chunk;
146
197
  });
147
- child.on('error', (error) => resolve({ status: null, stdout, output: error.message }));
148
- child.on('close', (status) => resolve({ status, stdout, output }));
198
+ child.on('error', (error) => ended({ status: null, stdout, output: error.message }));
199
+ child.on('close', (status) => ended({ status, stdout, output }));
149
200
  });
150
201
  }
151
202
  export function failureOf(ran, program) {
152
203
  return ran.output.trim().slice(-CAUSE_CHARACTERS) || `${program} exited with ${ran.status ?? 'a signal'}`;
153
204
  }
154
- export async function installAdapter(kind) {
205
+ export async function installAdapter(kind, signal) {
155
206
  const adapter = CLIS[kind].adapter;
156
207
  if (adapter === null)
157
208
  return null;
158
209
  const release = KIT_PACKAGE.peerDependencies[adapter.package];
159
210
  if ((await installedAdapter(kind)) === release)
160
211
  return null;
161
- const ran = await run('npm', ['install', '--prefix', prefix(kind), '--no-save', '--no-audit', '--no-fund', '--omit=optional', `${adapter.package}@${release}`], INSTALL_MS);
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);
162
213
  return ran.status === 0 ? null : failureOf(ran, 'npm');
163
214
  }
164
- export async function installCli(kind) {
165
- const ran = await run('bash', ['-o', 'pipefail', '-c', CLIS[kind].install], INSTALL_MS, true);
215
+ export async function installCli(kind, signal) {
216
+ const ran = await run('bash', ['-o', 'pipefail', '-c', CLIS[kind].install], INSTALL_MS, true, signal);
166
217
  return ran.status === 0 ? null : failureOf(ran, 'the install');
167
218
  }
168
219
  function loginShell() {
@@ -171,11 +222,11 @@ function loginShell() {
171
222
  const FOUND = 'house-kit-cli ';
172
223
  const LOOKUP = `found=$(command -v "$1") || exit 0; case $found in /*) ;; *) found=$(pwd -P)/$found ;; esac; printf "${FOUND}%s\\n" "$found"`;
173
224
  const USER_BIN = 'case ":$PATH:" in *":$HOME/.local/bin:"*) ;; *) PATH="$PATH:$HOME/.local/bin" ;; esac; export PATH';
174
- function inLoginShell(script, argument = '') {
175
- return run(loginShell(), ['-l', '-i', '-c', `exec /bin/sh -c '${USER_BIN}; ${script}' sh ${argument}`], READ_MS, true);
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);
176
227
  }
177
- export async function locate(kind) {
178
- const ran = await inLoginShell(LOOKUP, CLIS[kind].bin);
228
+ export async function locate(kind, signal) {
229
+ const ran = await inLoginShell(LOOKUP, CLIS[kind].bin, signal);
179
230
  return ran.stdout.split('\n').findLast((line) => line.startsWith(FOUND))?.slice(FOUND.length) ?? null;
180
231
  }
181
232
  export async function loginPath() {
@@ -183,8 +234,14 @@ export async function loginPath() {
183
234
  const ran = await inLoginShell(`printf "${marker}%s\\n" "$PATH"`);
184
235
  return ran.stdout.split('\n').findLast((line) => line.startsWith(marker)).slice(marker.length);
185
236
  }
186
- export async function versionOf(path) {
187
- return VERSION.exec((await run(path, ['--version'], READ_MS)).stdout)?.[0] ?? null;
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;
188
245
  }
189
246
  function numbers(version) {
190
247
  return version.split(/[.-]/, 3).map(Number);