@agentshouse/kit 0.2.1-ce145e9aadbf6 → 0.2.1-cf3a50c90b895

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 (105) hide show
  1. package/README.md +40 -39
  2. package/dist/acp.js +48 -105
  3. package/dist/agents.js +153 -43
  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/calls.js +63 -0
  9. package/dist/chatgpt.js +32 -0
  10. package/dist/clis.js +127 -70
  11. package/dist/codex-main.js +58 -0
  12. package/dist/connection.js +125 -0
  13. package/dist/conversations.js +540 -305
  14. package/dist/copies.js +188 -128
  15. package/dist/end.js +27 -0
  16. package/dist/enrol-main.js +7 -1
  17. package/dist/events.js +525 -0
  18. package/dist/files.js +22 -2
  19. package/dist/git.js +11 -9
  20. package/dist/home.js +96 -7
  21. package/dist/house-cli.js +291 -0
  22. package/dist/house-main.js +7 -159
  23. package/dist/instructions.js +10 -32
  24. package/dist/kit-cli.js +68 -0
  25. package/dist/kit-main.js +8 -19
  26. package/dist/login.js +118 -103
  27. package/dist/logout.js +14 -13
  28. package/dist/mcp.js +2 -2
  29. package/dist/parts.js +82 -0
  30. package/dist/placement.js +39 -0
  31. package/dist/refusals.js +12 -0
  32. package/dist/relay.js +12 -2
  33. package/dist/resident.js +106 -50
  34. package/dist/scope.js +206 -0
  35. package/dist/secret-input.js +3 -2
  36. package/dist/service-main.js +9 -0
  37. package/dist/service.js +55 -0
  38. package/dist/shell.js +3 -0
  39. package/dist/sign-in.js +37 -31
  40. package/dist/skills.js +84 -15
  41. package/dist/transcription.js +57 -9
  42. package/dist/translate.js +2 -8
  43. package/dist/update.js +71 -50
  44. package/dist/wire.js +22 -0
  45. package/dist/work.js +346 -0
  46. package/how-we-work.md +13 -3
  47. package/package.json +4 -3
  48. package/skills/ask-matt/SKILL.md +17 -11
  49. package/skills/ask-matt/agents/openai.yaml +1 -0
  50. package/skills/code-review/SKILL.md +5 -5
  51. package/skills/codebase-design/DESIGN-IT-TWICE.md +1 -1
  52. package/skills/codebase-design/SKILL.md +0 -2
  53. package/skills/diagnosing-bugs/SKILL.md +2 -4
  54. package/skills/domain-modeling/{CONTEXT-FORMAT.md → GLOSSARY-FORMAT.md} +10 -10
  55. package/skills/domain-modeling/SKILL.md +12 -14
  56. package/skills/grill-me/SKILL.md +1 -2
  57. package/skills/grill-me/agents/openai.yaml +1 -0
  58. package/skills/grill-with-docs/SKILL.md +1 -2
  59. package/skills/grill-with-docs/agents/openai.yaml +1 -0
  60. package/skills/grilling/SKILL.md +2 -2
  61. package/skills/handoff/SKILL.md +2 -3
  62. package/skills/handoff/agents/openai.yaml +1 -0
  63. package/skills/implement/SKILL.md +5 -4
  64. package/skills/implement/agents/openai.yaml +1 -0
  65. package/skills/implement-spec/SKILL.md +40 -0
  66. package/skills/implement-spec/agents/openai.yaml +5 -0
  67. package/skills/improve-codebase-architecture/SKILL.md +6 -7
  68. package/skills/improve-codebase-architecture/agents/openai.yaml +1 -0
  69. package/skills/pr/CREDITS.md +3 -0
  70. package/skills/pr/LICENSE +21 -0
  71. package/skills/pr/SKILL.md +170 -0
  72. package/skills/pr/agents/openai.yaml +3 -0
  73. package/skills/prototype/SKILL.md +0 -2
  74. package/skills/research/SKILL.md +0 -2
  75. package/skills/retro/LICENSE +21 -0
  76. package/skills/retro/SKILL.md +44 -0
  77. package/skills/retro/agents/openai.yaml +5 -0
  78. package/skills/tdd/SKILL.md +2 -4
  79. package/skills/teach/SKILL.md +3 -4
  80. package/skills/teach/agents/openai.yaml +1 -0
  81. package/skills/to-questionnaire/SKILL.md +1 -2
  82. package/skills/to-questionnaire/agents/openai.yaml +1 -0
  83. package/skills/to-spec/SKILL.md +1 -2
  84. package/skills/to-spec/agents/openai.yaml +1 -0
  85. package/skills/to-tickets/SKILL.md +3 -4
  86. package/skills/to-tickets/agents/openai.yaml +1 -0
  87. package/skills/triage/AGENT-BRIEF.md +207 -0
  88. package/skills/triage/LICENSE +21 -0
  89. package/skills/triage/OUT-OF-SCOPE.md +105 -0
  90. package/skills/triage/SKILL.md +112 -0
  91. package/skills/triage/agents/openai.yaml +5 -0
  92. package/skills/wait-what/SKILL.md +2 -3
  93. package/skills/wait-what/agents/openai.yaml +1 -0
  94. package/skills/wayfinder/SKILL.md +5 -6
  95. package/skills/wayfinder/agents/openai.yaml +1 -0
  96. package/skills/wizard/LICENSE +21 -0
  97. package/skills/wizard/SKILL.md +44 -0
  98. package/skills/wizard/agents/openai.yaml +3 -0
  99. package/skills/wizard/template.sh +223 -0
  100. package/skills/writing-for-agents/SKILL.md +0 -2
  101. package/dist/blocks.js +0 -51
  102. package/dist/stream.js +0 -47
  103. package/skills/resolving-merge-conflicts/SKILL.md +0 -16
  104. package/skills/resolving-merge-conflicts/agents/openai.yaml +0 -3
  105. /package/skills/{resolving-merge-conflicts → implement-spec}/LICENSE +0 -0
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
- export const KIT_VERSION = KIT_PACKAGE.version;
140
+ export const KIT_RELEASE = 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);
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process';
3
+ import { connect } from 'node:net';
4
+ const KIT_ANSWER = '"house-kit-';
5
+ const SYNC = 'house-kit/sync';
6
+ function lines(each) {
7
+ let buffer = '';
8
+ return (chunk) => {
9
+ buffer += chunk;
10
+ for (let index = buffer.indexOf('\n'); index >= 0; index = buffer.indexOf('\n')) {
11
+ each(buffer.slice(0, index + 1));
12
+ buffer = buffer.slice(index + 1);
13
+ }
14
+ };
15
+ }
16
+ function answersKit(line) {
17
+ if (!line.includes(KIT_ANSWER))
18
+ return false;
19
+ try {
20
+ const message = JSON.parse(line);
21
+ return message.method === undefined && typeof message.id === 'string' && message.id.startsWith(KIT_ANSWER.slice(1));
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ }
27
+ const args = process.argv.slice(2);
28
+ const serving = args[0] === 'app-server';
29
+ if (process.env.HOUSE_KIT_SANDBOX === '1' && serving)
30
+ args.push('--disable', 'memories');
31
+ const codex = spawn(process.env.HOUSE_KIT_CODEX, args, { stdio: ['pipe', 'pipe', 'inherit'] });
32
+ const stream = serving ? connect(process.env.HOUSE_KIT_CODEX_STREAM) : null;
33
+ stream?.on('error', () => undefined);
34
+ process.stdin.setEncoding('utf8').on('data', lines((line) => codex.stdin.write(line)));
35
+ process.stdin.on('end', () => codex.stdin.end());
36
+ stream?.setEncoding('utf8').on('data', lines((line) => {
37
+ const message = JSON.parse(line);
38
+ if (message.method === SYNC)
39
+ stream.write(`${JSON.stringify({ jsonrpc: '2.0', id: message.id, result: {} })}\n`);
40
+ else
41
+ codex.stdin.write(line);
42
+ }));
43
+ codex.stdout.setEncoding('utf8').on('data', lines((line) => {
44
+ stream?.write(line);
45
+ if (answersKit(line) || process.stdout.write(line))
46
+ return;
47
+ codex.stdout.pause();
48
+ process.stdout.once('drain', () => codex.stdout.resume());
49
+ }));
50
+ codex.stdin.on('error', () => undefined);
51
+ for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP'])
52
+ process.on(signal, () => codex.kill(signal));
53
+ codex.on('exit', (code, signal) => {
54
+ if (signal === null)
55
+ process.exit(code ?? 1);
56
+ process.removeAllListeners(signal);
57
+ process.kill(process.pid, signal);
58
+ });
@@ -0,0 +1,125 @@
1
+ import { subscribe } from 'node:diagnostics_channel';
2
+ import { HouseRefusal, retryDelay } from './api.js';
3
+ import { end, REPLACED, signInAgain } from './end.js';
4
+ import { readEnrolment } from './home.js';
5
+ import { relayed } from './relay.js';
6
+ import { endUnserved, KIT_WIRE } from './wire.js';
7
+ const PATH = '/kit/stream';
8
+ const SUBPROTOCOL = `house.kit.stream.${KIT_WIRE}`;
9
+ const REFUSAL_HEADER = 'x-house-refusal';
10
+ // House beats each Kit connection every thirty seconds, so two silent beats mean this Kit process cannot hear House.
11
+ export const CONNECTION_SILENCE_MS = 60_000;
12
+ const SYSTEM_CONNECTION_CLOCK = {
13
+ later: (delay, callback) => setTimeout(callback, delay),
14
+ clear: (timer) => clearTimeout(timer),
15
+ };
16
+ export class ConnectionSilence {
17
+ timer = null;
18
+ silent;
19
+ clock;
20
+ interval;
21
+ constructor(silent, clock = SYSTEM_CONNECTION_CLOCK, interval = CONNECTION_SILENCE_MS) {
22
+ this.silent = silent;
23
+ this.clock = clock;
24
+ this.interval = interval;
25
+ }
26
+ heard() {
27
+ this.stop();
28
+ this.timer = this.clock.later(this.interval, () => {
29
+ this.timer = null;
30
+ this.silent();
31
+ });
32
+ }
33
+ stop() {
34
+ if (this.timer === null)
35
+ return;
36
+ this.clock.clear(this.timer);
37
+ this.timer = null;
38
+ }
39
+ }
40
+ function connectionUrl(house) {
41
+ const url = new URL(PATH, house);
42
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
43
+ return url;
44
+ }
45
+ function header(headers, name) {
46
+ for (let index = 0; index < headers.length; index += 2) {
47
+ if (headers[index].toString('latin1').toLowerCase() === name)
48
+ return headers[index + 1].toString('latin1');
49
+ }
50
+ return '';
51
+ }
52
+ let refused = null;
53
+ subscribe('undici:request:headers', (message) => {
54
+ const { request, response } = message;
55
+ if (!request.path.endsWith(PATH))
56
+ return;
57
+ refused = new HouseRefusal(PATH, response.statusCode, header(response.headers, REFUSAL_HEADER) || response.statusText);
58
+ });
59
+ export function holdConnection(handlers) {
60
+ let connection = null;
61
+ let failures = 0;
62
+ const connect = async () => {
63
+ const { house, credential } = (await readEnrolment());
64
+ refused = null;
65
+ const opening = new WebSocket(relayed(connectionUrl(house)), {
66
+ protocols: [SUBPROTOCOL],
67
+ headers: { authorization: `Bearer ${credential}` },
68
+ });
69
+ let opened = false;
70
+ let silence = null;
71
+ const closed = (event) => {
72
+ silence?.stop();
73
+ silence = null;
74
+ if (connection === opening)
75
+ connection = null;
76
+ process.stderr.write(`kit: connection closed ${event.code} ${event.reason}\n`);
77
+ if (event.reason === 'replaced')
78
+ end(REPLACED);
79
+ if (event.reason === 'credential_rejected' || refused?.status === 401) {
80
+ void signInAgain(credential);
81
+ return;
82
+ }
83
+ if (refused !== null)
84
+ endUnserved(refused.text);
85
+ if (!opened && refused !== null)
86
+ process.stderr.write(`kit: ${refused.message}\n`);
87
+ // A closed connection reopens in one to three seconds, spread so Kit processes one restart closed return apart; an opening House refused or left unanswered backs off on the jittered curve, retryable or not, since a resident outlives a House deploy.
88
+ setTimeout(() => void connect(), opened ? 1000 + Math.random() * 2000 : retryDelay(failures++));
89
+ };
90
+ opening.onopen = () => {
91
+ opened = true;
92
+ failures = 0;
93
+ connection = opening;
94
+ silence = new ConnectionSilence(() => {
95
+ if (connection !== opening)
96
+ return;
97
+ connection = null;
98
+ opening.onclose = null;
99
+ opening.close();
100
+ closed(new CloseEvent('close', { code: 1006, reason: 'heartbeat_missed' }));
101
+ });
102
+ silence.heard();
103
+ handlers.opened();
104
+ };
105
+ opening.onmessage = (event) => {
106
+ silence?.heard();
107
+ const arrived = JSON.parse(String(event.data));
108
+ if (arrived.type === 'input')
109
+ handlers.call(arrived);
110
+ else if (arrived.type !== 'beat')
111
+ handlers.notification(arrived);
112
+ };
113
+ opening.onerror = () => undefined;
114
+ opening.onclose = closed;
115
+ };
116
+ void connect();
117
+ return {
118
+ send(notification) {
119
+ if (connection === null || connection.readyState !== WebSocket.OPEN)
120
+ return false;
121
+ connection.send(JSON.stringify(notification));
122
+ return true;
123
+ },
124
+ };
125
+ }