@agentshouse/kit 0.2.1-c5262a8cc063d → 0.2.1-cb0bd1cadcfed
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/agents.js +49 -21
- package/dist/api.js +32 -3
- package/dist/bootstrap.json +4 -4
- package/dist/bridge.js +29 -19
- package/dist/clis.js +21 -4
- package/dist/conversations.js +12 -8
- package/dist/copies.js +29 -36
- package/dist/end.js +8 -0
- package/dist/files.js +2 -1
- package/dist/instructions.js +1 -1
- package/dist/mcp.js +2 -1
- package/dist/refusals.js +12 -0
- package/dist/relay.js +10 -0
- package/dist/secret-input.js +4 -1
- package/dist/sign-in.js +2 -1
- package/dist/skills.js +1 -1
- package/dist/stream.js +31 -5
- package/dist/transcription.js +54 -6
- package/dist/update.js +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -49,8 +49,8 @@ commit to House; in the container, run it through the install command from the
|
|
|
49
49
|
copy's directory. Inside an Agent conversation the Agent runs `house push`.
|
|
50
50
|
|
|
51
51
|
House Kit turns each audio file of your message to an Agent into text on this
|
|
52
|
-
computer before the Agent reads
|
|
53
|
-
|
|
52
|
+
computer before the Agent reads it. While transcription is on, the speech
|
|
53
|
+
engine and model use about 425 MB in `~/.house-kit`; turning it off deletes them.
|
|
54
54
|
|
|
55
55
|
## License
|
|
56
56
|
|
package/dist/agents.js
CHANGED
|
@@ -3,13 +3,17 @@ import { readFile, realpath } from 'node:fs/promises';
|
|
|
3
3
|
import { killTree, startAdapter } from './acp.js';
|
|
4
4
|
import { CLIS, KIT_VERSION, below, installAdapter, installCli, locate, run, versionOf } from './clis.js';
|
|
5
5
|
import { agentBase, kitHome } from './home.js';
|
|
6
|
-
|
|
6
|
+
import { holdTranscription, transcriptionReport } from './transcription.js';
|
|
7
|
+
const SIGNED_OUT = { signed_in: false, models: [], modes: [] };
|
|
7
8
|
function select(options, category) {
|
|
8
9
|
const option = options.find((candidate) => candidate.category === category);
|
|
9
10
|
return option?.type === 'select' ? option : undefined;
|
|
10
11
|
}
|
|
12
|
+
function choices(options) {
|
|
13
|
+
return options.flatMap((entry) => ('group' in entry ? entry.options : [entry]));
|
|
14
|
+
}
|
|
11
15
|
function values(options) {
|
|
12
|
-
return
|
|
16
|
+
return choices(options).map((entry) => entry.value);
|
|
13
17
|
}
|
|
14
18
|
function causeOf(error) {
|
|
15
19
|
return error instanceof Error ? error.message : String(error);
|
|
@@ -21,9 +25,11 @@ async function models(kind, adapter, known) {
|
|
|
21
25
|
const agent = adapter.connection.agent;
|
|
22
26
|
const opened = await agent.request('session/new', { cwd: kitHome(), mcpServers: [] });
|
|
23
27
|
let options = opened.configOptions ?? [];
|
|
28
|
+
const mode = select(options, 'mode');
|
|
29
|
+
const modes = mode === undefined ? [] : choices(mode.options).map(({ value, name }) => ({ mode: value, name }));
|
|
24
30
|
const model = select(options, 'model');
|
|
25
31
|
if (model === undefined)
|
|
26
|
-
return [];
|
|
32
|
+
return { models: [], modes };
|
|
27
33
|
const offered = [];
|
|
28
34
|
for (const value of values(model.options)) {
|
|
29
35
|
if (select(options, 'model')?.currentValue !== value) {
|
|
@@ -39,10 +45,18 @@ async function models(kind, adapter, known) {
|
|
|
39
45
|
const effort = select(options, 'thought_level');
|
|
40
46
|
offered.push({ model: value, efforts: effort === undefined ? [] : values(effort.options) });
|
|
41
47
|
}
|
|
42
|
-
return offered;
|
|
48
|
+
return { models: offered, modes };
|
|
49
|
+
}
|
|
50
|
+
async function signedIn(kind, cli, known) {
|
|
51
|
+
const probe = CLIS[kind].signedIn;
|
|
52
|
+
// A sign-in check that has not answered in half a minute counts as signed out rather than stalling the report.
|
|
53
|
+
if ('command' in probe)
|
|
54
|
+
return (await run(cli, probe.command, 30_000)).status === 0;
|
|
55
|
+
return (await offer(kind, cli, known)).signed_in;
|
|
43
56
|
}
|
|
44
57
|
async function offer(kind, cli, known) {
|
|
45
58
|
const probe = CLIS[kind].signedIn;
|
|
59
|
+
// A sign-in check that has not answered in half a minute counts as signed out rather than stalling the report.
|
|
46
60
|
if ('command' in probe && (await run(cli, probe.command, 30_000)).status !== 0)
|
|
47
61
|
return SIGNED_OUT;
|
|
48
62
|
let adapter;
|
|
@@ -53,11 +67,15 @@ async function offer(kind, cli, known) {
|
|
|
53
67
|
if (typeof method !== 'string' || method.length === 0)
|
|
54
68
|
return SIGNED_OUT;
|
|
55
69
|
}
|
|
56
|
-
return { signed_in: true,
|
|
70
|
+
return { signed_in: true, ...(await models(kind, adapter, known.models)) };
|
|
57
71
|
}
|
|
58
72
|
catch (error) {
|
|
59
73
|
process.stderr.write(`kit: ${kind} did not offer its models: ${causeOf(error)}\n`);
|
|
60
|
-
return {
|
|
74
|
+
return {
|
|
75
|
+
signed_in: adapter === undefined && 'initializeMeta' in probe ? known.signed_in : true,
|
|
76
|
+
models: known.models,
|
|
77
|
+
modes: known.modes,
|
|
78
|
+
};
|
|
61
79
|
}
|
|
62
80
|
finally {
|
|
63
81
|
if (adapter !== undefined)
|
|
@@ -72,6 +90,8 @@ function reported(kind, reading) {
|
|
|
72
90
|
failure: reading.failure,
|
|
73
91
|
signed_in: reading.signed_in,
|
|
74
92
|
models: reading.models,
|
|
93
|
+
modes: reading.modes,
|
|
94
|
+
full_access: CLIS[kind].fullAccess,
|
|
75
95
|
};
|
|
76
96
|
}
|
|
77
97
|
function tooOld(kind, release) {
|
|
@@ -79,17 +99,19 @@ function tooOld(kind, release) {
|
|
|
79
99
|
return `${cli.bin} ${release} is older than ${cli.minimum}, the oldest this Kit runs`;
|
|
80
100
|
}
|
|
81
101
|
async function sshHostKey() {
|
|
102
|
+
// Listing a local host key takes a moment, so ten seconds only bounds a stuck ssh-keygen.
|
|
82
103
|
const listed = await run('ssh-keygen', ['-l', '-E', 'sha256', '-f', '/etc/ssh/ssh_host_ed25519_key.pub'], 10_000);
|
|
83
104
|
return listed.status === 0 ? (/^\d+ (SHA256:\S+)/.exec(listed.output)?.[1] ?? null) : null;
|
|
84
105
|
}
|
|
85
106
|
async function operatingSystem() {
|
|
107
|
+
// sw_vers answers at once, so ten seconds only bounds a stuck call.
|
|
86
108
|
if (process.platform === 'darwin')
|
|
87
109
|
return `macOS ${(await run('sw_vers', ['-productVersion'], 10_000)).output.trim()}`;
|
|
88
110
|
const release = await readFile('/etc/os-release', 'utf8');
|
|
89
111
|
return /^PRETTY_NAME="?([^"\n]*)"?$/m.exec(release)?.[1] ?? release;
|
|
90
112
|
}
|
|
91
113
|
export class Agents {
|
|
92
|
-
desired = { agents: [], routes: [] };
|
|
114
|
+
desired = { agents: [], routes: [], transcribe: false };
|
|
93
115
|
house;
|
|
94
116
|
reading = Promise.resolve();
|
|
95
117
|
queues = new Map();
|
|
@@ -113,8 +135,11 @@ export class Agents {
|
|
|
113
135
|
return this.desired;
|
|
114
136
|
});
|
|
115
137
|
this.reading = reading;
|
|
116
|
-
return reading.then(({ agents }) => {
|
|
117
|
-
|
|
138
|
+
return reading.then(({ agents, transcribe }) => {
|
|
139
|
+
holdTranscription(transcribe)
|
|
140
|
+
.then((changed) => (changed ? this.report() : undefined))
|
|
141
|
+
.catch(logged);
|
|
142
|
+
Promise.all(agents.map((kind) => this.load(kind)))
|
|
118
143
|
.then(() => this.report())
|
|
119
144
|
.catch(logged);
|
|
120
145
|
});
|
|
@@ -130,7 +155,7 @@ export class Agents {
|
|
|
130
155
|
const known = this.readings.get(kind);
|
|
131
156
|
const kept = known?.release === null || known?.minimum !== undefined ? SIGNED_OUT : (known ?? SIGNED_OUT);
|
|
132
157
|
if (known?.release !== release || known.minimum !== undefined) {
|
|
133
|
-
this.update(kind, { release, failure: null, signed_in: kept.signed_in, models: kept.models });
|
|
158
|
+
this.update(kind, { release, failure: null, signed_in: kept.signed_in, models: kept.models, modes: kept.modes });
|
|
134
159
|
}
|
|
135
160
|
const read = this.nextRead(kind);
|
|
136
161
|
offer(kind, cli, kept)
|
|
@@ -147,10 +172,12 @@ export class Agents {
|
|
|
147
172
|
return this.queue(kind, () => this.ensure(kind));
|
|
148
173
|
}
|
|
149
174
|
async reread(kind) {
|
|
150
|
-
await this.
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
175
|
+
await this.examine(kind, this.readings.get(kind) ?? SIGNED_OUT, offer);
|
|
176
|
+
await this.report();
|
|
177
|
+
}
|
|
178
|
+
async recheck(kind) {
|
|
179
|
+
const known = this.readings.get(kind) ?? SIGNED_OUT;
|
|
180
|
+
await this.examine(kind, known, async (kind, cli) => (await signedIn(kind, cli, known)) ? { ...known, signed_in: true } : SIGNED_OUT);
|
|
154
181
|
await this.report();
|
|
155
182
|
}
|
|
156
183
|
report() {
|
|
@@ -207,18 +234,18 @@ export class Agents {
|
|
|
207
234
|
return { release, minimum, failure: null, ...SIGNED_OUT };
|
|
208
235
|
return { cli, release };
|
|
209
236
|
}
|
|
210
|
-
async examine(kind, known) {
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
237
|
+
async examine(kind, known, read) {
|
|
238
|
+
const current = this.nextRead(kind);
|
|
239
|
+
const resolved = await this.queue(kind, () => this.resolve(kind));
|
|
240
|
+
const reading = 'cli' in resolved ? { release: resolved.release, failure: null, ...(await read(kind, resolved.cli, known)) } : resolved;
|
|
241
|
+
if (this.reads.get(kind) === current)
|
|
242
|
+
this.readings.set(kind, reading);
|
|
215
243
|
}
|
|
216
244
|
async load(kind) {
|
|
217
245
|
const known = this.readings.get(kind);
|
|
218
246
|
if (known !== undefined && known.failure === null)
|
|
219
247
|
return;
|
|
220
|
-
this.
|
|
221
|
-
this.readings.set(kind, await this.examine(kind, SIGNED_OUT));
|
|
248
|
+
await this.examine(kind, SIGNED_OUT, offer);
|
|
222
249
|
}
|
|
223
250
|
async send() {
|
|
224
251
|
const agents = this.desired.agents.flatMap((kind) => {
|
|
@@ -231,6 +258,7 @@ export class Agents {
|
|
|
231
258
|
kit_version: KIT_VERSION,
|
|
232
259
|
agent_base: agentBase(),
|
|
233
260
|
agents,
|
|
261
|
+
transcription: transcriptionReport(),
|
|
234
262
|
...(hostKey === null ? {} : { ssh_host_key: hostKey }),
|
|
235
263
|
};
|
|
236
264
|
const state = JSON.stringify(body);
|
package/dist/api.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
2
|
+
import { end, SIGN_IN_AGAIN } from './end.js';
|
|
1
3
|
import { readEnrolment } from './home.js';
|
|
4
|
+
import { retryable } from './refusals.js';
|
|
5
|
+
import { relayed } from './relay.js';
|
|
2
6
|
export class HouseRefusal extends Error {
|
|
3
7
|
status;
|
|
4
8
|
text;
|
|
@@ -8,11 +12,28 @@ export class HouseRefusal extends Error {
|
|
|
8
12
|
this.text = text;
|
|
9
13
|
}
|
|
10
14
|
}
|
|
15
|
+
export function finalRefusal(error) {
|
|
16
|
+
return error instanceof HouseRefusal && retryable(error.text) === false;
|
|
17
|
+
}
|
|
11
18
|
export function retryDelay(attempt) {
|
|
19
|
+
// 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.
|
|
12
20
|
return Math.random() * Math.min(30_000, 1000 * 2 ** attempt);
|
|
13
21
|
}
|
|
22
|
+
export async function retrying(sent, signal) {
|
|
23
|
+
for (let attempt = 0;; attempt++) {
|
|
24
|
+
try {
|
|
25
|
+
return await sent();
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
if (!(error instanceof HouseRefusal) || retryable(error.text) !== true)
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
// A request House refused as retryable is sent again on the jittered curve.
|
|
32
|
+
await delay(retryDelay(attempt), undefined, { signal });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
14
35
|
export async function post(house, path, body, credential, signal) {
|
|
15
|
-
const answer = await fetch(new URL(path, house), {
|
|
36
|
+
const answer = await fetch(relayed(new URL(path, house)), {
|
|
16
37
|
method: 'POST',
|
|
17
38
|
headers: {
|
|
18
39
|
'content-type': 'application/json',
|
|
@@ -28,7 +49,14 @@ export async function post(house, path, body, credential, signal) {
|
|
|
28
49
|
}
|
|
29
50
|
async function enrolledPost(path, body, signal) {
|
|
30
51
|
const { house, credential } = (await readEnrolment());
|
|
31
|
-
|
|
52
|
+
try {
|
|
53
|
+
return await post(house, path, body, credential, signal);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (error instanceof HouseRefusal && error.status === 401)
|
|
57
|
+
end(SIGN_IN_AGAIN);
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
32
60
|
}
|
|
33
61
|
export const house = {
|
|
34
62
|
post: enrolledPost,
|
|
@@ -38,9 +66,10 @@ export const house = {
|
|
|
38
66
|
return await enrolledPost(path, body);
|
|
39
67
|
}
|
|
40
68
|
catch (error) {
|
|
41
|
-
if (error
|
|
69
|
+
if (finalRefusal(error))
|
|
42
70
|
throw error;
|
|
43
71
|
process.stderr.write(`kit: ${path} did not reach House: ${error.message}\n`);
|
|
72
|
+
// A delivery House did not take is sent again on the jittered curve for as long as it is still current.
|
|
44
73
|
await new Promise((resolve) => setTimeout(resolve, retryDelay(attempt)));
|
|
45
74
|
if (!current())
|
|
46
75
|
throw error;
|
package/dist/bootstrap.json
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
|
-
"version": "0.2.1-
|
|
3
|
+
"version": "0.2.1-cb0bd1cadcfed",
|
|
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-
|
|
8
|
+
"url": "https://github.com/agentshouse/kit/releases/download/v0.2.1-cb0bd1cadcfed/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-
|
|
14
|
+
"url": "https://github.com/agentshouse/kit/releases/download/v0.2.1-cb0bd1cadcfed/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-
|
|
20
|
+
"url": "https://github.com/agentshouse/kit/releases/download/v0.2.1-cb0bd1cadcfed/connect-windows.ps1"
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
23
|
}
|
package/dist/bridge.js
CHANGED
|
@@ -6,13 +6,14 @@ import { createServer, request as httpRequest } from 'node:http';
|
|
|
6
6
|
import { request as httpsRequest } from 'node:https';
|
|
7
7
|
import { delimiter, dirname, join } from 'node:path';
|
|
8
8
|
import { parse } from 'yaml';
|
|
9
|
-
import { HouseRefusal, retryDelay } from './api.js';
|
|
9
|
+
import { finalRefusal, HouseRefusal, retryDelay, retrying } from './api.js';
|
|
10
10
|
import { loginPath } from './clis.js';
|
|
11
11
|
import { changedPaths, } from './copies.js';
|
|
12
12
|
import { kitHome, readEnrolment } from './home.js';
|
|
13
13
|
import { callHouse, mcpBody, rpc, UNREACHABLE } from './mcp.js';
|
|
14
14
|
import { operationId, uuidOf } from './operation.js';
|
|
15
|
-
import { refusalOf } from './refusals.js';
|
|
15
|
+
import { refusalOf, retryable } from './refusals.js';
|
|
16
|
+
import { relayed } from './relay.js';
|
|
16
17
|
const MCP_REQUEST_BYTES = 4 * 1024 * 1024;
|
|
17
18
|
const ATTACHMENTS = '/kit/attachments/upload';
|
|
18
19
|
export async function received(request) {
|
|
@@ -22,7 +23,8 @@ export async function received(request) {
|
|
|
22
23
|
text += chunk;
|
|
23
24
|
return text;
|
|
24
25
|
}
|
|
25
|
-
function relay(request, response,
|
|
26
|
+
function relay(request, response, house, authorization) {
|
|
27
|
+
const target = relayed(house);
|
|
26
28
|
const send = target.protocol === 'https:' ? httpsRequest : httpRequest;
|
|
27
29
|
const forwarded = send(target, {
|
|
28
30
|
method: request.method,
|
|
@@ -38,8 +40,6 @@ function refusalIn(text, status) {
|
|
|
38
40
|
return refusalOf(text) ?? { code: '', text: text === '' ? `House refused it with ${status}` : text, conflicts: [] };
|
|
39
41
|
}
|
|
40
42
|
function answered(forwarded) {
|
|
41
|
-
if (forwarded.status >= 500)
|
|
42
|
-
throw new HouseRefusal('/', forwarded.status, forwarded.text);
|
|
43
43
|
if (forwarded.status !== 200)
|
|
44
44
|
return { refused: refusalIn(forwarded.text, forwarded.status) };
|
|
45
45
|
const answer = JSON.parse(forwarded.text);
|
|
@@ -63,8 +63,8 @@ function pendingSeconds(forwarded) {
|
|
|
63
63
|
function resendAfter(forwarded, attempt) {
|
|
64
64
|
if (forwarded === null)
|
|
65
65
|
return retryDelay(attempt);
|
|
66
|
-
if (forwarded.status
|
|
67
|
-
return
|
|
66
|
+
if (forwarded.status !== 200)
|
|
67
|
+
return retryable(forwarded.text) === false ? null : retryDelay(attempt);
|
|
68
68
|
const seconds = pendingSeconds(forwarded);
|
|
69
69
|
return seconds === null ? null : seconds * 1000;
|
|
70
70
|
}
|
|
@@ -103,6 +103,7 @@ export async function openBridge(house, copies, conversationId, signal) {
|
|
|
103
103
|
const after = resendAfter(forwarded, attempt);
|
|
104
104
|
if (after === null)
|
|
105
105
|
return forwarded;
|
|
106
|
+
// A write House did not settle is sent again on the jittered curve, or after the wait House names for a pending one.
|
|
106
107
|
await delay(after, undefined, { signal: closed.signal });
|
|
107
108
|
}
|
|
108
109
|
};
|
|
@@ -117,17 +118,26 @@ export async function openBridge(house, copies, conversationId, signal) {
|
|
|
117
118
|
if ('refused' in prepared)
|
|
118
119
|
return prepared;
|
|
119
120
|
const { transfer } = parse(prepared.text);
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
121
|
+
let text;
|
|
122
|
+
try {
|
|
123
|
+
text = await retrying(async () => {
|
|
124
|
+
const posted = await fetch(relayed(transfer.url), {
|
|
125
|
+
method: 'POST',
|
|
126
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
127
|
+
body: Buffer.concat([Buffer.from(`${transfer.operation}\n`), bytes]),
|
|
128
|
+
signal: closed.signal,
|
|
129
|
+
});
|
|
130
|
+
const answer = await posted.text();
|
|
131
|
+
if (!posted.ok)
|
|
132
|
+
throw new HouseRefusal('/', posted.status, answer);
|
|
133
|
+
return answer;
|
|
134
|
+
}, closed.signal);
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
if (!finalRefusal(error))
|
|
138
|
+
throw error;
|
|
139
|
+
return { refused: refusalIn(error.text, error.status) };
|
|
140
|
+
}
|
|
131
141
|
const held = JSON.parse(text).transfer;
|
|
132
142
|
return { url: held.url, operation: held.operation, bytes: bytes.length, sha256 };
|
|
133
143
|
};
|
|
@@ -150,7 +160,7 @@ export async function openBridge(house, copies, conversationId, signal) {
|
|
|
150
160
|
const tool = message.method === 'tools/call' ? String(message.params.name) : null;
|
|
151
161
|
return send(message, tool !== null && (await writes(tool)) ? operationId() : null);
|
|
152
162
|
}
|
|
153
|
-
const answer = await fetch(new URL(path, origin), {
|
|
163
|
+
const answer = await fetch(relayed(new URL(path, origin)), {
|
|
154
164
|
method: 'POST',
|
|
155
165
|
headers: { 'content-type': 'application/json', authorization: `Bearer ${credential}` },
|
|
156
166
|
body: versioned(conversationId, text),
|
package/dist/clis.js
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
|
-
import { readFile } from 'node:fs/promises';
|
|
4
|
-
import { userInfo } from 'node:os';
|
|
3
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { homedir, userInfo } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { kitHome } from './home.js';
|
|
7
7
|
const RUNNING_JOB = new Set(['running', 'paused']);
|
|
8
|
+
async function allowHouseInCodex() {
|
|
9
|
+
const rules = join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'rules');
|
|
10
|
+
await mkdir(rules, { recursive: true });
|
|
11
|
+
await writeFile(join(rules, 'house.rules'), 'prefix_rule(pattern = ["house"], decision = "allow")\n');
|
|
12
|
+
}
|
|
13
|
+
async function nothing() { }
|
|
8
14
|
function threadStatus({ method, params }) {
|
|
9
15
|
if (method !== 'session/update' || params?.update?.sessionUpdate !== 'session_info_update')
|
|
10
16
|
return undefined;
|
|
@@ -36,6 +42,9 @@ export const CLIS = {
|
|
|
36
42
|
install: 'curl -fsSL https://chatgpt.com/codex/install.sh | sh',
|
|
37
43
|
adapter: { package: '@agentclientprotocol/codex-acp', bin: 'codex-acp', executable: 'CODEX_PATH', env: {} },
|
|
38
44
|
args: [],
|
|
45
|
+
fullAccess: 'agent-full-access',
|
|
46
|
+
sessionMeta: {},
|
|
47
|
+
allowHouse: allowHouseInCodex,
|
|
39
48
|
signedIn: { command: ['login', 'status'] },
|
|
40
49
|
login: { args: ['login', '--device-auth'], code: 'show' },
|
|
41
50
|
queues: false,
|
|
@@ -52,9 +61,12 @@ export const CLIS = {
|
|
|
52
61
|
package: '@agentclientprotocol/claude-agent-acp',
|
|
53
62
|
bin: 'claude-agent-acp',
|
|
54
63
|
executable: 'CLAUDE_CODE_EXECUTABLE',
|
|
55
|
-
env: { CLAUDE_CODE_ENTRYPOINT: 'claude-agent-acp' },
|
|
64
|
+
env: { CLAUDE_CODE_ENTRYPOINT: 'claude-agent-acp', IS_SANDBOX: '1' },
|
|
56
65
|
},
|
|
57
66
|
args: [],
|
|
67
|
+
fullAccess: 'bypassPermissions',
|
|
68
|
+
sessionMeta: { claudeCode: { options: { allowedTools: ['Bash(house:*)'] } } },
|
|
69
|
+
allowHouse: nothing,
|
|
58
70
|
signedIn: { command: ['auth', 'status'] },
|
|
59
71
|
login: { args: ['auth', 'login', '--claudeai'], code: 'collect', rejected: 'Invalid code' },
|
|
60
72
|
queues: false,
|
|
@@ -70,7 +82,10 @@ export const CLIS = {
|
|
|
70
82
|
minimum: '1.0.46',
|
|
71
83
|
install: 'curl -fsSL https://x.ai/cli/install.sh | bash',
|
|
72
84
|
adapter: null,
|
|
73
|
-
args: ['agent', '--no-leader', 'stdio'],
|
|
85
|
+
args: ['agent', '--always-approve', '--no-leader', 'stdio'],
|
|
86
|
+
fullAccess: null,
|
|
87
|
+
sessionMeta: {},
|
|
88
|
+
allowHouse: nothing,
|
|
74
89
|
signedIn: { initializeMeta: 'defaultAuthMethodId' },
|
|
75
90
|
login: { args: ['login', '--device-auth'], code: 'show' },
|
|
76
91
|
queues: true,
|
|
@@ -90,7 +105,9 @@ const KIT_PACKAGE = JSON.parse(readFileSync(new URL('../package.json', import.me
|
|
|
90
105
|
export const KIT_VERSION = KIT_PACKAGE.version;
|
|
91
106
|
const PROXY = /^https?_proxy$/i;
|
|
92
107
|
const VERSION = /\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?/;
|
|
108
|
+
// An install still running after fifteen minutes is ended and reported as failed, so a hung download cannot hold its CLI's report.
|
|
93
109
|
const INSTALL_MS = 15 * 60_000;
|
|
110
|
+
// A login shell or a version read is ended after half a minute, so a profile that waits for input cannot stall the report.
|
|
94
111
|
const READ_MS = 30_000;
|
|
95
112
|
const CAUSE_CHARACTERS = 4000;
|
|
96
113
|
function prefix(kind) {
|
package/dist/conversations.js
CHANGED
|
@@ -209,7 +209,7 @@ export class Conversations {
|
|
|
209
209
|
continue;
|
|
210
210
|
try {
|
|
211
211
|
const text = await transcribe(path, AbortSignal.any([conversation.killed.signal, conversation.interrupted.signal]));
|
|
212
|
-
if (text !== '')
|
|
212
|
+
if (text !== null && text !== '')
|
|
213
213
|
lines.push(`Transcript: ${text}`);
|
|
214
214
|
}
|
|
215
215
|
catch (error) {
|
|
@@ -285,13 +285,15 @@ export class Conversations {
|
|
|
285
285
|
conversation.opening = bridge;
|
|
286
286
|
try {
|
|
287
287
|
await unlessAborted(createHowWeWork(bridge), conversation.killed.signal);
|
|
288
|
+
await CLIS[route.kind].allowHouse();
|
|
288
289
|
const adapter = await startAdapter(route.kind, cli, route.working_directory, app, (job) => this.job(conversation, job), bridge.env);
|
|
289
290
|
void adapter.exited.then(() => bridge.close());
|
|
290
291
|
const agent = adapter.connection.agent;
|
|
291
292
|
const cwd = route.working_directory;
|
|
293
|
+
const _meta = CLIS[route.kind].sessionMeta;
|
|
292
294
|
const opened = sessionId === null
|
|
293
|
-
? await agent.request('session/new', { cwd, mcpServers: [] })
|
|
294
|
-
: { ...(await agent.request('session/resume', { sessionId, cwd, mcpServers: [] })), sessionId };
|
|
295
|
+
? await agent.request('session/new', { cwd, mcpServers: [], _meta })
|
|
296
|
+
: { ...(await agent.request('session/resume', { sessionId, cwd, mcpServers: [], _meta })), sessionId };
|
|
295
297
|
const running = {
|
|
296
298
|
adapter,
|
|
297
299
|
bridge,
|
|
@@ -321,6 +323,7 @@ export class Conversations {
|
|
|
321
323
|
async launchSettings(running, route, offered) {
|
|
322
324
|
let options = offered;
|
|
323
325
|
const settings = [
|
|
326
|
+
['mode', route.mode ?? CLIS[route.kind].fullAccess],
|
|
324
327
|
['model', route.model],
|
|
325
328
|
['thought_level', route.effort],
|
|
326
329
|
];
|
|
@@ -389,10 +392,8 @@ export class Conversations {
|
|
|
389
392
|
turn.admitted = true;
|
|
390
393
|
for (const note of turn.notes.splice(0))
|
|
391
394
|
this.kit.send(note);
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
this.draft(conversation, turn);
|
|
395
|
-
}
|
|
395
|
+
turn.unsentFrom = 0;
|
|
396
|
+
this.draft(conversation, turn);
|
|
396
397
|
if (turn.entries !== null)
|
|
397
398
|
this.plan(conversation, turn);
|
|
398
399
|
}
|
|
@@ -442,8 +443,11 @@ export class Conversations {
|
|
|
442
443
|
this.attach(conversation.turn ?? this.begin(conversation), sent);
|
|
443
444
|
}
|
|
444
445
|
running.adapter.connection.agent.request('session/prompt', { sessionId: running.sessionId, prompt }).then(() => this.answered(conversation, sent, null), async (error) => {
|
|
445
|
-
if (!running.adapter.connection.signal.aborted)
|
|
446
|
+
if (!running.adapter.connection.signal.aborted) {
|
|
446
447
|
this.answered(conversation, sent, causeOf(error));
|
|
448
|
+
if (conversation.open === 0 && conversation.running === running)
|
|
449
|
+
stop(running.bridge);
|
|
450
|
+
}
|
|
447
451
|
else if (sent.turn !== null) {
|
|
448
452
|
this.end(conversation, sent.turn, { failed: running.killed ? KILLED : await running.adapter.exited });
|
|
449
453
|
}
|
package/dist/copies.js
CHANGED
|
@@ -2,15 +2,15 @@ import { parse } from '@agentshouse/mdmodel';
|
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { mkdir, readFile, readdir, realpath, rename, rm, rmdir, stat, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
|
-
import { HouseRefusal } from './api.js';
|
|
5
|
+
import { finalRefusal, HouseRefusal, retrying } from './api.js';
|
|
6
6
|
import { blobId, blobText, git, HOUSE_REF, importCommits, localCopies, revision, run, succeeded, tree, } from './git.js';
|
|
7
7
|
import { kitHome } from './home.js';
|
|
8
8
|
import { operationId } from './operation.js';
|
|
9
9
|
import { refusalOf } from './refusals.js';
|
|
10
10
|
import { changed } from './translate.js';
|
|
11
|
+
import { relayed } from './relay.js';
|
|
11
12
|
const BRANCH = 'main';
|
|
12
13
|
const PROVENANCE = '_provenance';
|
|
13
|
-
const REPLICA_RETRY_MS = 30_000;
|
|
14
14
|
const IDENTITY = { GIT_COMMITTER_NAME: 'House Kit', GIT_COMMITTER_EMAIL: 'house-kit@localhost' };
|
|
15
15
|
function causeOf(error) {
|
|
16
16
|
return error instanceof Error ? error.message : String(error);
|
|
@@ -40,11 +40,11 @@ function sidecar(path) {
|
|
|
40
40
|
segments.splice(-1, 0, PROVENANCE);
|
|
41
41
|
return segments.join('/');
|
|
42
42
|
}
|
|
43
|
-
function rootOf(
|
|
44
|
-
return
|
|
43
|
+
function rootOf(handle) {
|
|
44
|
+
return `/rooms/${handle}`;
|
|
45
45
|
}
|
|
46
46
|
function relative(path) {
|
|
47
|
-
return path.replace(/^\/
|
|
47
|
+
return path.replace(/^\/rooms\/[^/]+\//, '');
|
|
48
48
|
}
|
|
49
49
|
function declaredWritable(path, copy) {
|
|
50
50
|
const deepest = (scopes) => Math.max(-1, ...scopes.filter((scope) => covers(path, scope)).map((scope) => scope.length));
|
|
@@ -160,11 +160,11 @@ export class LocalCopies {
|
|
|
160
160
|
key: 'owner',
|
|
161
161
|
submit: async (operation, changes) => {
|
|
162
162
|
try {
|
|
163
|
-
const outcome = await house.post('/kit/door/batch', { operation_id: operation, changes });
|
|
163
|
+
const outcome = await retrying(() => house.post('/kit/door/batch', { operation_id: operation, changes }));
|
|
164
164
|
return { accepted: outcome.contents };
|
|
165
165
|
}
|
|
166
166
|
catch (error) {
|
|
167
|
-
if (!(error
|
|
167
|
+
if (!finalRefusal(error))
|
|
168
168
|
throw error;
|
|
169
169
|
return {
|
|
170
170
|
refused: refusalOf(error.text) ?? { code: '', text: `House refused it with ${error.status}`, conflicts: [] },
|
|
@@ -176,7 +176,7 @@ export class LocalCopies {
|
|
|
176
176
|
async load() {
|
|
177
177
|
const home = join(kitHome(), 'local-copies');
|
|
178
178
|
const names = (await readdir(join(home, 'rooms')).catch(() => [])).filter((name) => name.endsWith('.json'));
|
|
179
|
-
for (const path of
|
|
179
|
+
for (const path of names.map((name) => join(home, 'rooms', name))) {
|
|
180
180
|
const text = await readFile(path, 'utf8').catch(() => null);
|
|
181
181
|
if (text === null)
|
|
182
182
|
continue;
|
|
@@ -188,7 +188,7 @@ export class LocalCopies {
|
|
|
188
188
|
await this.selecting;
|
|
189
189
|
return [...this.copies.values()]
|
|
190
190
|
.filter((copy) => copy.active)
|
|
191
|
-
.map((copy) => `${rootOf(copy)}: ${copy.repository}`)
|
|
191
|
+
.map((copy) => `${rootOf(copy.room_handle)}: ${copy.repository}`)
|
|
192
192
|
.sort();
|
|
193
193
|
}
|
|
194
194
|
select() {
|
|
@@ -198,12 +198,7 @@ export class LocalCopies {
|
|
|
198
198
|
}
|
|
199
199
|
async reselect() {
|
|
200
200
|
const { local_copy: chosen } = await this.house.post('/kit/local-copy/selection', {});
|
|
201
|
-
const selected = [
|
|
202
|
-
...(chosen?.rooms ?? [])
|
|
203
|
-
.filter((room) => room.room_handle !== null)
|
|
204
|
-
.map((room) => ({ key: `rooms/${room.room_ref}`, private: false, handle: room.room_handle })),
|
|
205
|
-
...(chosen?.private === true ? [{ key: 'private', private: true, handle: null }] : []),
|
|
206
|
-
];
|
|
201
|
+
const selected = (chosen?.rooms ?? []).flatMap((room) => room.room_handle === null ? [] : [{ key: `rooms/${room.room_ref}`, handle: room.room_handle }]);
|
|
207
202
|
const keys = new Set(selected.map((scope) => scope.key));
|
|
208
203
|
const work = [];
|
|
209
204
|
for (const key of this.copies.keys()) {
|
|
@@ -287,7 +282,7 @@ export class LocalCopies {
|
|
|
287
282
|
}).catch(logged);
|
|
288
283
|
}
|
|
289
284
|
async save(copy) {
|
|
290
|
-
const path = join(kitHome(), 'local-copies',
|
|
285
|
+
const path = join(kitHome(), 'local-copies', `${copy.key}.json`);
|
|
291
286
|
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
292
287
|
const temporary = `${path}.${process.pid}`;
|
|
293
288
|
await writeFile(temporary, JSON.stringify(copy), { mode: 0o600 });
|
|
@@ -295,12 +290,12 @@ export class LocalCopies {
|
|
|
295
290
|
this.copies.set(copy.key, copy);
|
|
296
291
|
return copy;
|
|
297
292
|
}
|
|
298
|
-
async read(
|
|
299
|
-
const prefixes = [rootOf(
|
|
293
|
+
async read(handle) {
|
|
294
|
+
const prefixes = [rootOf(handle)];
|
|
300
295
|
const discovery = await this.house.post('/kit/door/discover', { prefixes });
|
|
301
296
|
let bundle = await this.house.post('/kit/door/bootstrap', { prefixes });
|
|
302
297
|
if ('transfer' in bundle)
|
|
303
|
-
bundle = (await (await fetch(bundle.transfer.url)).json());
|
|
298
|
+
bundle = (await (await fetch(relayed(bundle.transfer.url))).json());
|
|
304
299
|
const [view] = bundle.scopes;
|
|
305
300
|
if (view === undefined)
|
|
306
301
|
return null;
|
|
@@ -343,13 +338,13 @@ export class LocalCopies {
|
|
|
343
338
|
return;
|
|
344
339
|
}
|
|
345
340
|
const discovery = await this.house.post('/kit/door/discover', {
|
|
346
|
-
prefixes: [rootOf(copy)],
|
|
341
|
+
prefixes: [rootOf(copy.room_handle)],
|
|
347
342
|
});
|
|
348
343
|
const authority = discovery.authorities.find((entry) => entry.room_ref === copy.room_ref);
|
|
349
344
|
if (authority === undefined)
|
|
350
345
|
return;
|
|
351
346
|
if (authority.managed_replication !== 'admitted') {
|
|
352
|
-
logged(`operation_denied: ${rootOf(copy)} is not synchronized to this Environment`);
|
|
347
|
+
logged(`operation_denied: ${rootOf(copy.room_handle)} is not synchronized to this Environment`);
|
|
353
348
|
return;
|
|
354
349
|
}
|
|
355
350
|
if (JSON.stringify([authority.operations.write, authority.protected_paths]) !==
|
|
@@ -360,12 +355,12 @@ export class LocalCopies {
|
|
|
360
355
|
await this.save(await this.caughtUp(copy));
|
|
361
356
|
}
|
|
362
357
|
async materialize(scope) {
|
|
363
|
-
const read = await this.read(
|
|
358
|
+
const read = await this.read(scope.handle);
|
|
364
359
|
if (read === null) {
|
|
365
|
-
logged(`House serves nothing of ${rootOf(
|
|
360
|
+
logged(`House serves nothing of ${rootOf(scope.handle)} to copy`);
|
|
366
361
|
return;
|
|
367
362
|
}
|
|
368
|
-
const repository =
|
|
363
|
+
const repository = join(localCopies(), 'rooms', read.anchor);
|
|
369
364
|
if (await exists(repository)) {
|
|
370
365
|
logged(`${repository} already exists and House Kit leaves it as it is`);
|
|
371
366
|
return;
|
|
@@ -377,7 +372,7 @@ export class LocalCopies {
|
|
|
377
372
|
try {
|
|
378
373
|
await git(staging, ['init', '-q', '-b', BRANCH]);
|
|
379
374
|
house = await importCommits(staging, [
|
|
380
|
-
{ message: `House ${rootOf(
|
|
375
|
+
{ message: `House ${rootOf(scope.handle)}`, from: null, files: read.writable },
|
|
381
376
|
]);
|
|
382
377
|
await git(staging, ['update-ref', `refs/heads/${BRANCH}`, house]);
|
|
383
378
|
await git(staging, ['reset', '-q', '--hard']);
|
|
@@ -391,7 +386,6 @@ export class LocalCopies {
|
|
|
391
386
|
}
|
|
392
387
|
await this.save({
|
|
393
388
|
key: scope.key,
|
|
394
|
-
private: scope.private,
|
|
395
389
|
room_ref: read.anchor,
|
|
396
390
|
room_handle: scope.handle,
|
|
397
391
|
repository,
|
|
@@ -407,11 +401,11 @@ export class LocalCopies {
|
|
|
407
401
|
});
|
|
408
402
|
}
|
|
409
403
|
async received(copy) {
|
|
410
|
-
const read = await this.read(copy);
|
|
404
|
+
const read = await this.read(copy.room_handle);
|
|
411
405
|
if (read === null)
|
|
412
406
|
return copy;
|
|
413
407
|
const house = await importCommits(copy.repository, [
|
|
414
|
-
{ message: `House ${rootOf(copy)} at position ${read.position}`, from: copy.house, replaces: true, files: read.writable },
|
|
408
|
+
{ message: `House ${rootOf(copy.room_handle)} at position ${read.position}`, from: copy.house, replaces: true, files: read.writable },
|
|
415
409
|
]);
|
|
416
410
|
const removed = copy.read_only.filter((path) => !read.readOnly.has(path));
|
|
417
411
|
const readOnly = await readOnlyPlaced(copy.repository, copy.read_only, read.readOnly, removed);
|
|
@@ -434,7 +428,7 @@ export class LocalCopies {
|
|
|
434
428
|
let answer;
|
|
435
429
|
try {
|
|
436
430
|
answer = await this.house.post('/kit/door/enumerate', {
|
|
437
|
-
prefixes: [rootOf(copy)],
|
|
431
|
+
prefixes: [rootOf(copy.room_handle)],
|
|
438
432
|
positions: [{ authority: copy.room_ref, position: copy.position, log_epoch: copy.log_epoch }],
|
|
439
433
|
});
|
|
440
434
|
}
|
|
@@ -444,7 +438,6 @@ export class LocalCopies {
|
|
|
444
438
|
return this.received(copy);
|
|
445
439
|
if (code !== 'replica_behind')
|
|
446
440
|
throw error;
|
|
447
|
-
setTimeout(() => this.deliver(copy.key), REPLICA_RETRY_MS).unref();
|
|
448
441
|
return copy;
|
|
449
442
|
}
|
|
450
443
|
const view = answer.authorities.find((entry) => entry.authority === copy.room_ref);
|
|
@@ -484,7 +477,7 @@ export class LocalCopies {
|
|
|
484
477
|
let { house, manifests } = copy;
|
|
485
478
|
if (files.size > 0) {
|
|
486
479
|
house = await importCommits(copy.repository, [
|
|
487
|
-
{ message: `House ${rootOf(copy)} at position ${position}`, from: copy.house, files },
|
|
480
|
+
{ message: `House ${rootOf(copy.room_handle)} at position ${position}`, from: copy.house, files },
|
|
488
481
|
]);
|
|
489
482
|
manifests = { ...manifests, [house]: { parent: copy.house, files: revisions } };
|
|
490
483
|
}
|
|
@@ -515,7 +508,7 @@ export class LocalCopies {
|
|
|
515
508
|
copy = await this.save(without(copy));
|
|
516
509
|
}
|
|
517
510
|
if (!copy.active) {
|
|
518
|
-
return { refused: true, text: `${rootOf(copy)} is no longer synced to this Environment, so House takes no push from it` };
|
|
511
|
+
return { refused: true, text: `${rootOf(copy.room_handle)} is no longer synced to this Environment, so House takes no push from it` };
|
|
519
512
|
}
|
|
520
513
|
const listed = (await git(copy.repository, ['rev-list', '--topo-order', commit])).split('\n');
|
|
521
514
|
const base = listed.find((ancestor) => ancestor in copy.manifests);
|
|
@@ -549,7 +542,7 @@ export class LocalCopies {
|
|
|
549
542
|
return this.settle(submitted, caller);
|
|
550
543
|
}
|
|
551
544
|
async derived(copy, base, commit) {
|
|
552
|
-
const root = rootOf(copy);
|
|
545
|
+
const root = rootOf(copy.room_handle);
|
|
553
546
|
const before = await tree(copy.repository, base);
|
|
554
547
|
const after = await tree(copy.repository, commit);
|
|
555
548
|
const moved = movedPaths(copy.moves, before, after);
|
|
@@ -631,7 +624,7 @@ export class LocalCopies {
|
|
|
631
624
|
const push = copy.push;
|
|
632
625
|
const current = await this.save(await this.caughtUp(copy));
|
|
633
626
|
const house = await tree(copy.repository, current.house);
|
|
634
|
-
const root = rootOf(current);
|
|
627
|
+
const root = rootOf(current.room_handle);
|
|
635
628
|
if (!(await this.landed(copy.repository, push.changes, house, await tree(copy.repository, push.commit)))) {
|
|
636
629
|
await this.save(without(current));
|
|
637
630
|
return {
|
|
@@ -687,7 +680,7 @@ export class LocalCopies {
|
|
|
687
680
|
written = [
|
|
688
681
|
...written,
|
|
689
682
|
{
|
|
690
|
-
path: `${rootOf(copy)}/${path}`,
|
|
683
|
+
path: `${rootOf(copy.room_handle)}/${path}`,
|
|
691
684
|
revision: entry === undefined ? null : (revisionAt(copy, copy.house, path) ?? null),
|
|
692
685
|
content: entry === undefined ? null : await blobText(copy.repository, entry, path),
|
|
693
686
|
},
|
|
@@ -707,7 +700,7 @@ export class LocalCopies {
|
|
|
707
700
|
}
|
|
708
701
|
const edits = new Map([...files].filter(([path, content]) => (content === null ? selected.has(path) : selected.get(path) !== blobId(content))));
|
|
709
702
|
const received = [...files.keys()].filter((path) => revisionAt(copy, copy.house, path) === revisionAt(copy, push.house, path));
|
|
710
|
-
const accepted = `House ${rootOf(copy)} with ${push.commit} accepted`;
|
|
703
|
+
const accepted = `House ${rootOf(copy.room_handle)} with ${push.commit} accepted`;
|
|
711
704
|
const house = await importCommits(copy.repository, [
|
|
712
705
|
...(edits.size === 0 ? [] : [{ message: accepted, from: push.commit, files: edits }]),
|
|
713
706
|
{
|
package/dist/end.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { killDescendants } from './acp.js';
|
|
2
|
+
export const REPLACED = 'another Kit started for this Environment';
|
|
3
|
+
export const SIGN_IN_AGAIN = 'sign in again';
|
|
4
|
+
export function end(reason) {
|
|
5
|
+
process.stderr.write(`kit: ${reason}\n`);
|
|
6
|
+
killDescendants();
|
|
7
|
+
process.exit(0);
|
|
8
|
+
}
|
package/dist/files.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { relayed } from './relay.js';
|
|
3
4
|
export async function placeFiles(house, directory, files, signal) {
|
|
4
5
|
const paths = [];
|
|
5
6
|
for (const file of files) {
|
|
6
7
|
const original = await house.post('/kit/conversation/originals/get', { version: file.version, download: true }, signal);
|
|
7
|
-
const answer = await fetch(original.download.url, { signal });
|
|
8
|
+
const answer = await fetch(relayed(original.download.url), { signal });
|
|
8
9
|
if (!answer.ok)
|
|
9
10
|
throw new Error(`${file.name} did not download: ${answer.status}`);
|
|
10
11
|
const folder = join(directory, '.house', 'files', original.message, file.version);
|
package/dist/instructions.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { HOW_WE_WORK } from './skills.js';
|
|
2
2
|
const HOUSE_LINE = 'Work with House through the `house` CLI: run `house --help`.';
|
|
3
3
|
const FILE_LINE = 'To give the User a file or a page, upload it with `house upload_attachment` and link it; never start a server.';
|
|
4
|
-
const READ_LINE = 'Before working in a Room, read `/private/rooms/<handle>/AGENTS.md`.';
|
|
4
|
+
const READ_LINE = 'Before working in a Room, read `/rooms/private/rooms/<handle>/AGENTS.md`.';
|
|
5
5
|
const COPIES_LINE = 'House Local copies; commit, then `house push`:';
|
|
6
6
|
const UNREADABLE = /^(?:\S+: )?(?:path_not_found|room_not_found|operation_denied)\b/;
|
|
7
7
|
const FAILED = /^exit: [1-9]\d*$/;
|
package/dist/mcp.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { KIT_VERSION } from './clis.js';
|
|
3
|
+
import { relayed } from './relay.js';
|
|
3
4
|
const MCP_PROTOCOL_VERSION = '2026-07-28';
|
|
4
5
|
export const UNREACHABLE = 'House is unreachable; call again in a minute';
|
|
5
6
|
export function rpc(method, params) {
|
|
@@ -21,7 +22,7 @@ export function mcpBody(message, operation) {
|
|
|
21
22
|
}
|
|
22
23
|
export async function callHouse(origin, credential, message, operation = null, signal) {
|
|
23
24
|
const tool = message.method === 'tools/call' ? String(message.params.name) : null;
|
|
24
|
-
const answer = await fetch(new URL('/', origin), {
|
|
25
|
+
const answer = await fetch(relayed(new URL('/', origin)), {
|
|
25
26
|
method: 'POST',
|
|
26
27
|
headers: {
|
|
27
28
|
'content-type': 'application/json',
|
package/dist/refusals.js
CHANGED
|
@@ -21,6 +21,18 @@ function spoken(text) {
|
|
|
21
21
|
const detail = said.indexOf('\n');
|
|
22
22
|
return { code: head[1], text: said, conflicts: detail === -1 ? [] : conflictCodes(said.slice(detail + 1)) };
|
|
23
23
|
}
|
|
24
|
+
export function retryable(text) {
|
|
25
|
+
let body;
|
|
26
|
+
try {
|
|
27
|
+
body = JSON.parse(text);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const error = body?.error;
|
|
33
|
+
const verdict = error?.retryable ?? error?.data?.retryable;
|
|
34
|
+
return typeof verdict === 'boolean' ? verdict : null;
|
|
35
|
+
}
|
|
24
36
|
export function refusalOf(text) {
|
|
25
37
|
let body;
|
|
26
38
|
try {
|
package/dist/relay.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function relayed(target) {
|
|
2
|
+
const url = new URL(target);
|
|
3
|
+
const relay = process.env.HOUSE_KIT_RELAY;
|
|
4
|
+
if (relay === undefined || relay === '')
|
|
5
|
+
return url;
|
|
6
|
+
const through = new URL(`${url.host}${url.pathname}${url.search}`, `${relay}/`);
|
|
7
|
+
if (url.protocol === 'wss:' || url.protocol === 'ws:')
|
|
8
|
+
through.protocol = 'wss:';
|
|
9
|
+
return through;
|
|
10
|
+
}
|
package/dist/secret-input.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { retryDelay } from './api.js';
|
|
1
|
+
import { finalRefusal, retryDelay } from './api.js';
|
|
2
2
|
export async function holdSecretInput(house, subject, steps, waiting) {
|
|
3
3
|
for (let failures = 0; waiting();) {
|
|
4
4
|
try {
|
|
@@ -10,7 +10,10 @@ export async function holdSecretInput(house, subject, steps, waiting) {
|
|
|
10
10
|
failures = 0;
|
|
11
11
|
}
|
|
12
12
|
catch (error) {
|
|
13
|
+
if (finalRefusal(error))
|
|
14
|
+
return null;
|
|
13
15
|
process.stderr.write(`kit: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
16
|
+
// A wait that did not reach House is held again on the jittered curve for as long as its asker still waits.
|
|
14
17
|
await new Promise((resolve) => setTimeout(resolve, retryDelay(failures++)));
|
|
15
18
|
}
|
|
16
19
|
}
|
package/dist/sign-in.js
CHANGED
|
@@ -31,8 +31,9 @@ export class SignIns {
|
|
|
31
31
|
this.running.add(input.input_id);
|
|
32
32
|
this.changed();
|
|
33
33
|
this.login(input)
|
|
34
|
-
.then(() => this.agents.
|
|
34
|
+
.then(() => this.agents.recheck(input.cli))
|
|
35
35
|
.then(() => this.house.deliver(`/kit/inputs/${input.input_id}/ack`, {}))
|
|
36
|
+
.then(() => this.agents.reread(input.cli))
|
|
36
37
|
.catch(logged)
|
|
37
38
|
.finally(() => {
|
|
38
39
|
this.running.delete(input.input_id);
|
package/dist/skills.js
CHANGED
|
@@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
6
6
|
import { KIT_VERSION } from './clis.js';
|
|
7
7
|
import { kitHome, readConfiguration, readHome, writeHome } from './home.js';
|
|
8
8
|
import { refusalOf } from './refusals.js';
|
|
9
|
-
export const HOW_WE_WORK = '/private/how-we-work.md';
|
|
9
|
+
export const HOW_WE_WORK = '/rooms/private/how-we-work.md';
|
|
10
10
|
const SKILL_SET = fileURLToPath(new URL('../skills/', import.meta.url));
|
|
11
11
|
const HOW_WE_WORK_TEXT = readFileSync(new URL('../how-we-work.md', import.meta.url), 'utf8');
|
|
12
12
|
const RECORD = 'skill-set.json';
|
package/dist/stream.js
CHANGED
|
@@ -1,17 +1,37 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { subscribe } from 'node:diagnostics_channel';
|
|
2
|
+
import { finalRefusal, HouseRefusal, retryDelay } from './api.js';
|
|
3
|
+
import { end, REPLACED, SIGN_IN_AGAIN } from './end.js';
|
|
2
4
|
import { readEnrolment } from './home.js';
|
|
5
|
+
import { relayed } from './relay.js';
|
|
6
|
+
const PATH = '/kit/stream';
|
|
3
7
|
const SUBPROTOCOL = 'house.kit.stream.1';
|
|
8
|
+
const REFUSAL_HEADER = 'x-house-refusal';
|
|
4
9
|
function streamUrl(house) {
|
|
5
|
-
const url = new URL(
|
|
10
|
+
const url = new URL(PATH, house);
|
|
6
11
|
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
7
12
|
return url;
|
|
8
13
|
}
|
|
14
|
+
function header(headers, name) {
|
|
15
|
+
for (let index = 0; index < headers.length; index += 2) {
|
|
16
|
+
if (headers[index].toString('latin1').toLowerCase() === name)
|
|
17
|
+
return headers[index + 1].toString('latin1');
|
|
18
|
+
}
|
|
19
|
+
return '';
|
|
20
|
+
}
|
|
21
|
+
let refused = null;
|
|
22
|
+
subscribe('undici:request:headers', (message) => {
|
|
23
|
+
const { request, response } = message;
|
|
24
|
+
if (!request.path.endsWith(PATH))
|
|
25
|
+
return;
|
|
26
|
+
refused = new HouseRefusal(PATH, response.statusCode, header(response.headers, REFUSAL_HEADER) || response.statusText);
|
|
27
|
+
});
|
|
9
28
|
export function holdStream(handlers) {
|
|
10
29
|
let socket = null;
|
|
11
30
|
let failures = 0;
|
|
12
31
|
const connect = async () => {
|
|
13
32
|
const { house, credential } = (await readEnrolment());
|
|
14
|
-
|
|
33
|
+
refused = null;
|
|
34
|
+
const opening = new WebSocket(relayed(streamUrl(house)), {
|
|
15
35
|
protocols: [SUBPROTOCOL],
|
|
16
36
|
headers: { authorization: `Bearer ${credential}` },
|
|
17
37
|
});
|
|
@@ -30,8 +50,14 @@ export function holdStream(handlers) {
|
|
|
30
50
|
if (socket === opening)
|
|
31
51
|
socket = null;
|
|
32
52
|
process.stderr.write(`kit: control stream closed ${event.code} ${event.reason}\n`);
|
|
33
|
-
|
|
34
|
-
|
|
53
|
+
if (event.reason === 'replaced')
|
|
54
|
+
end(REPLACED);
|
|
55
|
+
if (event.reason === 'credential_rejected' || refused?.status === 401)
|
|
56
|
+
end(SIGN_IN_AGAIN);
|
|
57
|
+
if (finalRefusal(refused))
|
|
58
|
+
end(refused.message);
|
|
59
|
+
// A closed socket reopens in one to three seconds, spread so Kits one restart closed return apart; an opening House refused as retryable or left unanswered backs off on the jittered curve.
|
|
60
|
+
setTimeout(() => void connect(), opened ? 1000 + Math.random() * 2000 : retryDelay(failures++));
|
|
35
61
|
};
|
|
36
62
|
};
|
|
37
63
|
void connect();
|
package/dist/transcription.js
CHANGED
|
@@ -29,6 +29,10 @@ export const ENGINES = {
|
|
|
29
29
|
};
|
|
30
30
|
const CHUNK_SECONDS = 20;
|
|
31
31
|
let held = Promise.resolve();
|
|
32
|
+
let holding = Promise.resolve();
|
|
33
|
+
let setting = null;
|
|
34
|
+
let downloading = null;
|
|
35
|
+
let readiness = { installed: false, failure: null };
|
|
32
36
|
function directory() {
|
|
33
37
|
return join(kitHome(), 'transcription');
|
|
34
38
|
}
|
|
@@ -36,6 +40,7 @@ function installed(artifact) {
|
|
|
36
40
|
return join(directory(), artifact.sha256);
|
|
37
41
|
}
|
|
38
42
|
async function executed(command, args, signal) {
|
|
43
|
+
// A download or a transcription runs as long as its size needs; the setting turning off or the turn's interrupt or end aborts it instead.
|
|
39
44
|
const ran = await run(command, args, 0, false, signal);
|
|
40
45
|
if (ran.status !== 0)
|
|
41
46
|
throw new Error(failureOf(ran, basename(command)));
|
|
@@ -54,6 +59,12 @@ async function sha256(path) {
|
|
|
54
59
|
await pipeline(createReadStream(path), hash);
|
|
55
60
|
return hash.digest('hex');
|
|
56
61
|
}
|
|
62
|
+
function causeOf(error) {
|
|
63
|
+
return error instanceof Error ? error.message : String(error);
|
|
64
|
+
}
|
|
65
|
+
function pinnedHere() {
|
|
66
|
+
return ENGINES[`${process.platform}-${process.arch}`];
|
|
67
|
+
}
|
|
57
68
|
async function install(artifacts, signal) {
|
|
58
69
|
await mkdir(directory(), { recursive: true });
|
|
59
70
|
const pinned = new Set(artifacts.map((artifact) => artifact.sha256));
|
|
@@ -68,6 +79,7 @@ async function install(artifacts, signal) {
|
|
|
68
79
|
try {
|
|
69
80
|
for (const artifact of missing) {
|
|
70
81
|
const part = `${installed(artifact)}.part`;
|
|
82
|
+
// Three retries carry a large download past a dropped connection before the download fails.
|
|
71
83
|
await executed('curl', ['-fsSL', '--retry', '3', '-o', part, artifact.url], signal);
|
|
72
84
|
if ((await sha256(part)) !== artifact.sha256)
|
|
73
85
|
throw new Error(`${artifact.url} does not match its sha256`);
|
|
@@ -83,12 +95,46 @@ async function install(artifacts, signal) {
|
|
|
83
95
|
await rm(`${installed(artifact)}.part`, { force: true });
|
|
84
96
|
}
|
|
85
97
|
}
|
|
98
|
+
function reported(next) {
|
|
99
|
+
const changed = next.installed !== readiness.installed || next.failure !== readiness.failure;
|
|
100
|
+
readiness = next;
|
|
101
|
+
return changed;
|
|
102
|
+
}
|
|
103
|
+
async function settle(transcribe) {
|
|
104
|
+
if (transcribe === setting)
|
|
105
|
+
return false;
|
|
106
|
+
setting = transcribe;
|
|
107
|
+
if (!transcribe) {
|
|
108
|
+
await rm(directory(), { recursive: true, force: true });
|
|
109
|
+
return reported({ installed: false, failure: null });
|
|
110
|
+
}
|
|
111
|
+
const download = new AbortController();
|
|
112
|
+
downloading = download;
|
|
113
|
+
try {
|
|
114
|
+
const pinned = pinnedHere();
|
|
115
|
+
await install([pinned.decoder, pinned.engine, MODEL], download.signal);
|
|
116
|
+
return reported({ installed: true, failure: null });
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
await rm(directory(), { recursive: true, force: true });
|
|
120
|
+
return reported({ installed: false, failure: download.signal.aborted ? null : causeOf(error) });
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
downloading = null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
export function holdTranscription(transcribe) {
|
|
127
|
+
if (!transcribe)
|
|
128
|
+
downloading?.abort();
|
|
129
|
+
const settled = holding.then(() => settle(transcribe));
|
|
130
|
+
holding = settled.catch(() => undefined);
|
|
131
|
+
return settled;
|
|
132
|
+
}
|
|
133
|
+
export function transcriptionReport() {
|
|
134
|
+
return readiness;
|
|
135
|
+
}
|
|
86
136
|
async function transcribed(path, signal) {
|
|
87
|
-
const
|
|
88
|
-
const pinned = ENGINES[platform];
|
|
89
|
-
if (pinned === undefined)
|
|
90
|
-
throw new Error(`no transcription engine is pinned for ${platform}`);
|
|
91
|
-
await install([pinned.decoder, pinned.engine, MODEL], signal);
|
|
137
|
+
const pinned = pinnedHere();
|
|
92
138
|
const work = await mkdtemp(join(tmpdir(), 'kit-transcription-'));
|
|
93
139
|
try {
|
|
94
140
|
await executed(installed(pinned.decoder), ['-nostdin', '-hide_banner', '-loglevel', 'error', '-i', path, '-ar', '16000', '-ac', '1', '-c:a', 'pcm_s16le',
|
|
@@ -106,6 +152,8 @@ async function transcribed(path, signal) {
|
|
|
106
152
|
}
|
|
107
153
|
}
|
|
108
154
|
export async function transcribe(path, signal) {
|
|
155
|
+
if (!readiness.installed)
|
|
156
|
+
return null;
|
|
109
157
|
const before = held;
|
|
110
158
|
let release;
|
|
111
159
|
const mine = new Promise((resolve) => {
|
|
@@ -114,7 +162,7 @@ export async function transcribe(path, signal) {
|
|
|
114
162
|
held = Promise.all([before, mine]);
|
|
115
163
|
try {
|
|
116
164
|
await unlessAborted(before, signal);
|
|
117
|
-
return await transcribed(path, signal);
|
|
165
|
+
return readiness.installed ? await transcribed(path, signal) : null;
|
|
118
166
|
}
|
|
119
167
|
finally {
|
|
120
168
|
release();
|
package/dist/update.js
CHANGED
|
@@ -40,9 +40,11 @@ async function finished(inputId) {
|
|
|
40
40
|
const ack = await recorded();
|
|
41
41
|
if (ack !== null)
|
|
42
42
|
return ack;
|
|
43
|
+
// systemctl answers at once, so ten seconds only bounds a stuck service manager.
|
|
43
44
|
if ((await run('systemctl', ['is-active', '--quiet', unit(inputId)], 10_000)).status !== 0) {
|
|
44
45
|
return (await recorded()) ?? { refused: 'the Kit bootstrap did not finish' };
|
|
45
46
|
}
|
|
47
|
+
// The bootstrap runs for minutes, so checking each second sees its outcome within a second at little cost.
|
|
46
48
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
47
49
|
}
|
|
48
50
|
}
|