@agentshouse/kit 0.2.1-c64c31420c9bf → 0.2.1-c6ec4d4d781e7

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/dist/house-cli.js CHANGED
@@ -1,14 +1,18 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { createReadStream } from 'node:fs';
3
+ import { readFile } from 'node:fs/promises';
3
4
  import { request, setGlobalProxyFromEnv } from 'node:http';
4
- import { basename, dirname, extname, join, resolve } from 'node:path';
5
- import { verified } from './files.js';
5
+ import { request as httpsRequest } from 'node:https';
6
+ import { basename, extname, join, resolve } from 'node:path';
7
+ import { Readable } from 'node:stream';
8
+ import { pipeline } from 'node:stream/promises';
9
+ import { downloaded, inspectedFile, storeRefusal, uploaded } from './files.js';
6
10
  import { HOUSE_REF } from './git.js';
7
11
  import { ownerSocket, readOwnAgent } from './home.js';
8
- import { callHouse, rpc, UNREACHABLE } from './mcp.js';
9
- import { refusalOf } from './refusals.js';
12
+ import { callHouse, rpc, UNREACHABLE, Unsettled } from './mcp.js';
13
+ import { refusalOf, retryable } from './refusals.js';
10
14
  import { relayed, selectRelay } from './relay.js';
11
- import { parse, stringify } from 'yaml';
15
+ import { parse } from 'yaml';
12
16
  const USAGE = "usage: house <tool> ['<arguments as JSON>']";
13
17
  const PUSH_USAGE = 'usage: house push [commit] [--owner]';
14
18
  const PUSH = "push [commit]: sends the Local copy's committed changes to House";
@@ -51,15 +55,12 @@ const MEDIA_TYPES = new Map([
51
55
  ['xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
52
56
  ['zip', 'application/zip'],
53
57
  ]);
54
- function refused(path, method, status, text, local) {
58
+ function refused(path, status, text, local) {
55
59
  const refusal = refusalOf(text);
56
60
  if (refusal !== null)
57
61
  return refusal.text;
58
62
  if (status === 401)
59
63
  return REVOKED;
60
- const act = method === 'GET' ? 'download' : 'upload';
61
- if (URL.canParse(path))
62
- return `the ${act} answered ${status}; ${act} the file again`;
63
64
  if (status === 502 && local)
64
65
  return text;
65
66
  return path === '/' ? unconfirmed(status) : `${path} answered ${status}`;
@@ -67,26 +68,23 @@ function refused(path, method, status, text, local) {
67
68
  function unconfirmed(status) {
68
69
  return `House answered ${status} ${UNCONFIRMED}`;
69
70
  }
70
- function exchanged(path, method, body, headers = { 'content-type': 'application/json' }, to = socketPath) {
71
+ function bridged(path, body, to = socketPath) {
71
72
  return new Promise((resolve, reject) => {
72
- const sent = request({ socketPath: to, path, method, headers }, (answer) => {
73
+ const sent = request({ socketPath: to, path, method: 'POST', headers: { 'content-type': 'application/json' } }, (answer) => {
73
74
  const chunks = [];
74
75
  answer.on('data', (chunk) => chunks.push(chunk));
75
76
  answer.on('end', () => {
76
- const bytes = Buffer.concat(chunks);
77
+ const text = Buffer.concat(chunks).toString('utf8');
77
78
  if (answer.statusCode === 200)
78
- resolve(bytes);
79
+ resolve(text);
79
80
  else
80
- reject(new Error(refused(path, method, answer.statusCode, bytes.toString('utf8'), answer.headers['content-type'] === undefined)));
81
+ reject(new Error(refused(path, answer.statusCode, text, answer.headers['content-type'] === undefined)));
81
82
  });
82
83
  });
83
84
  sent.on('error', (error) => reject(new Error(error.code === 'EPERM' || error.code === 'EACCES' ? SANDBOXED : CLOSED)));
84
- sent.end(body === undefined ? undefined : body instanceof Buffer ? body : JSON.stringify(body));
85
+ sent.end(JSON.stringify(body));
85
86
  });
86
87
  }
87
- async function bridged(path, body, headers, to = socketPath) {
88
- return (await exchanged(path, 'POST', body, headers, to)).toString('utf8');
89
- }
90
88
  async function direct(message) {
91
89
  const ownAgent = await readOwnAgent();
92
90
  if (ownAgent === null)
@@ -94,11 +92,14 @@ async function direct(message) {
94
92
  selectRelay(process.env.HOUSE_KIT_RELAY || ownAgent.relay);
95
93
  setGlobalProxyFromEnv();
96
94
  const answer = await callHouse(ownAgent.house, ownAgent.credential, message).catch(() => {
97
- throw new Error(UNREACHABLE);
95
+ throw new Unsettled(UNREACHABLE);
98
96
  });
99
97
  if (answer.status === 200)
100
98
  return answer.text;
101
- throw new Error(answer.status === 401 ? DISCONNECTED : (refusalOf(answer.text)?.text ?? unconfirmed(answer.status)));
99
+ if (answer.status === 401)
100
+ throw new Error(DISCONNECTED);
101
+ const refusal = refusalOf(answer.text)?.text ?? unconfirmed(answer.status);
102
+ throw retryable(answer.text) === false ? new Error(refusal) : new Unsettled(refusal);
102
103
  }
103
104
  async function mcp(method, params) {
104
105
  const message = rpc(method, params);
@@ -110,31 +111,51 @@ async function mcp(method, params) {
110
111
  async function tools() {
111
112
  return (await mcp('tools/list', {})).tools;
112
113
  }
114
+ function unreadable(path, error) {
115
+ return new Error(error.code === 'EISDIR'
116
+ ? `${path} is a folder; upload one file at a time`
117
+ : `${path} is not a readable file`);
118
+ }
113
119
  async function localFile(path) {
114
120
  try {
115
121
  return await readFile(path);
116
122
  }
117
123
  catch (error) {
118
- throw new Error(error.code === 'EISDIR'
119
- ? `${path} is a folder; upload one file at a time`
120
- : `${path} is not a readable file`);
124
+ throw unreadable(path, error);
121
125
  }
122
126
  }
123
- async function transferred(url, method, bytes, headers) {
124
- if (socketPath !== undefined)
125
- return exchanged(url, method, bytes, headers);
126
- setGlobalProxyFromEnv();
127
- const answer = await fetch(relayed(url), { method, ...(headers === undefined ? {} : { headers }), ...(bytes === undefined ? {} : { body: bytes }) }).catch(() => {
128
- throw new Error(UNREACHABLE);
127
+ function opened(url, method, headers, body) {
128
+ return new Promise((answered, failed) => {
129
+ const local = socketPath !== undefined;
130
+ const target = local ? null : relayed(url);
131
+ if (!local)
132
+ setGlobalProxyFromEnv();
133
+ const sent = target === null
134
+ ? request({ socketPath, path: url, method, headers }, answered)
135
+ : (target.protocol === 'https:' ? httpsRequest : request)(target, { method, headers }, answered);
136
+ sent.on('error', (error) => failed(new Error(!local ? UNREACHABLE : error.code === 'EPERM' || error.code === 'EACCES' ? SANDBOXED : CLOSED)));
137
+ if (body instanceof Readable)
138
+ pipeline(body, sent).catch(() => undefined);
139
+ else
140
+ sent.end(body);
129
141
  });
130
- const received = Buffer.from(await answer.arrayBuffer());
131
- if (answer.status === 200)
132
- return received;
133
- throw new Error(refused(url, method, answer.status, received.toString('utf8'), false));
142
+ }
143
+ async function textOf(answer) {
144
+ return Buffer.concat(await answer.toArray()).toString('utf8');
134
145
  }
135
146
  async function sent(url, bytes, operation) {
136
147
  const headers = { 'content-type': 'application/octet-stream', 'x-house-byte-operation': operation };
137
- return (await transferred(url, 'POST', bytes, headers)).toString('utf8');
148
+ const answer = await opened(url, 'POST', headers, bytes);
149
+ const text = await textOf(answer);
150
+ if (answer.statusCode === 200)
151
+ return text;
152
+ throw new Error(refusalOf(text)?.text ?? storeRefusal('upload', answer.statusCode).message);
153
+ }
154
+ async function sentPart(url, file, start, end) {
155
+ const answer = await opened(url, 'PUT', { 'content-length': String(end - start) }, end === start ? undefined : createReadStream(file, { start, end: end - 1 }));
156
+ await answer.toArray();
157
+ if (answer.statusCode !== 200)
158
+ throw storeRefusal('upload', answer.statusCode);
138
159
  }
139
160
  async function download(args) {
140
161
  const { flags, positionals: [argument] } = accepted(args, ['--help'], 1, DOWNLOAD_USAGE);
@@ -143,37 +164,52 @@ async function download(args) {
143
164
  const { file, ...selected } = argumentsOf(argument);
144
165
  if (typeof file !== 'string' || file === '')
145
166
  throw new Error('download needs "file", the local file to write');
146
- const inspected = parse(await called('inspect', selected));
147
- if (typeof inspected?.download !== 'string')
148
- throw new Error(`${String(selected.path)} holds no binary file; shell reads its text`);
149
- const bytes = verified(await transferred(inspected.download, 'GET'), String(inspected.sha256), String(selected.path));
150
167
  const written = resolve(file);
151
- await mkdir(dirname(written), { recursive: true });
152
- await writeFile(written, bytes);
168
+ await downloaded(async () => {
169
+ const file = inspectedFile(await called('inspect', selected));
170
+ if (file === null)
171
+ throw new Error(`${String(selected.path)} holds no binary file; shell reads its text`);
172
+ return file;
173
+ }, async (url) => {
174
+ const answer = await opened(url, 'GET', {});
175
+ return { status: answer.statusCode, body: answer };
176
+ }, written, String(selected.path));
153
177
  return written;
154
178
  }
155
179
  async function described(file) {
156
- const bytes = await localFile(file);
180
+ const digest = createHash('sha256');
181
+ let bytes = 0;
182
+ try {
183
+ for await (const chunk of createReadStream(file)) {
184
+ digest.update(chunk);
185
+ bytes += chunk.length;
186
+ }
187
+ }
188
+ catch (error) {
189
+ throw unreadable(file, error);
190
+ }
157
191
  const mediaType = MEDIA_TYPES.get(extname(file).slice(1).toLowerCase());
158
- return {
159
- bytes,
160
- description: {
161
- bytes: bytes.length,
162
- sha256: createHash('sha256').update(bytes).digest('hex'),
163
- ...(mediaType === undefined ? {} : { media_type: mediaType }),
164
- },
165
- };
192
+ return { bytes, sha256: digest.digest('hex'), ...(mediaType === undefined ? {} : { media_type: mediaType }) };
193
+ }
194
+ async function finished(upload, room) {
195
+ const text = await called('upload', { upload, room_ref: room });
196
+ const answer = parse(text);
197
+ return answer !== null && ('upload' in answer || 'checking' in answer) ? answer : { text };
166
198
  }
167
199
  async function upload({ file, ...destination }) {
168
200
  if (typeof file !== 'string' || file === '')
169
201
  throw new Error('upload needs "file", a local file');
170
- const { bytes, description } = await described(file);
202
+ const description = await described(file);
171
203
  const answer = await called('upload', { ...destination, ...description });
172
204
  const declared = parse(answer);
173
- if (declared.upload === undefined)
205
+ if (declared?.upload === undefined)
174
206
  return answer;
175
- const written = JSON.parse(await sent(declared.upload.url, bytes, declared.upload.operation));
176
- return stringify(written, { lineWidth: 0 }).trimEnd();
207
+ const accepted = await uploaded(declared.upload, {
208
+ bytes: description.bytes,
209
+ send: (url, start, end) => sentPart(url, file, start, end),
210
+ finish: (id) => finished(id, destination.room_ref),
211
+ });
212
+ return accepted.text;
177
213
  }
178
214
  async function appendRecord(args) {
179
215
  const held = new Map();
@@ -181,8 +217,8 @@ async function appendRecord(args) {
181
217
  ? await Promise.all(args.attachments.map(async (attachment) => {
182
218
  if (typeof attachment !== 'string')
183
219
  return attachment;
184
- const { bytes, description } = await described(attachment);
185
- held.set(description.sha256, bytes);
220
+ const description = await described(attachment);
221
+ held.set(description.sha256, attachment);
186
222
  return { name: basename(attachment), ...description };
187
223
  }))
188
224
  : args.attachments;
@@ -193,7 +229,7 @@ async function appendRecord(args) {
193
229
  if (upload === undefined)
194
230
  return answer;
195
231
  for (const grant of upload)
196
- await sent(grant.url, held.get(grant.sha256), grant.operation);
232
+ await sent(grant.url, await localFile(held.get(grant.sha256)), grant.operation);
197
233
  }
198
234
  }
199
235
  function unexpected(argument, usage) {
@@ -220,7 +256,7 @@ async function push(args) {
220
256
  throw new Error('--owner is not for Agent conversations; run house push without it');
221
257
  if (!owner && socketPath === undefined)
222
258
  throw new Error('outside an Agent conversation, house push needs --owner');
223
- const pushed = JSON.parse(await bridged('/git/push', { cwd: process.cwd(), commit }, undefined, owner ? ownerSocket() : socketPath));
259
+ const pushed = JSON.parse(await bridged('/git/push', { cwd: process.cwd(), commit }, owner ? ownerSocket() : socketPath));
224
260
  if (pushed.refused)
225
261
  throw new Error(pushed.text);
226
262
  return pushed.text;
@@ -228,7 +264,7 @@ async function push(args) {
228
264
  async function fetched(args) {
229
265
  if (accepted(args, ['--help'], 0, FETCH_USAGE).flags.has('--help'))
230
266
  return FETCH_USAGE;
231
- const answer = JSON.parse(await bridged('/git/fetch', {}, undefined, socketPath ?? ownerSocket()));
267
+ const answer = JSON.parse(await bridged('/git/fetch', {}, socketPath ?? ownerSocket()));
232
268
  if (answer.refused)
233
269
  throw new Error(answer.text);
234
270
  return answer.text;
@@ -4,8 +4,9 @@ const SOURCE_LINE = "From House:";
4
4
  const HOUSE_LINE = "Work with House through the `house` CLI: run `house --help`, and `house find_command` for House's guides and formats.";
5
5
  const FILE_LINE = 'To give the User a file, upload it with `house upload` and link it; for an App, a Site or a website, follow `house find_command \'{"query":"app"}\'`. Never start a server.';
6
6
  const READ_LINE = "Optional per-Room instructions: `/rooms/private/rooms/<handle>/AGENTS.md`.";
7
+ const REQUEST_LINE = "For another Agent's help, `send_routine_request`; its mail comes back into this conversation. If an Agent of your User has no Routine to call, write one in the Private Room and `create_routine_access` with your User as its only caller.";
7
8
  const COPIES_LINE = `House Local copies; before work run \`house fetch\` and rebase onto \`${HOUSE_REF}\`; after it commit, then \`house push\`:`;
8
9
  export async function instructions(bridge, base, copies) {
9
- const lines = [SOURCE_LINE, HOUSE_LINE, FILE_LINE, READ_LINE, ...(copies.length === 0 ? [] : [COPIES_LINE, ...copies])];
10
+ const lines = [SOURCE_LINE, HOUSE_LINE, FILE_LINE, READ_LINE, REQUEST_LINE, ...(copies.length === 0 ? [] : [COPIES_LINE, ...copies])];
10
11
  return [lines.join('\n'), base, (await readHowWeWork(bridge)).replace(/\n$/, '')].filter((part) => part !== '').join('\n\n');
11
12
  }
package/dist/kit-cli.js CHANGED
@@ -11,7 +11,7 @@ const COMMANDS = new Map([
11
11
  'logout',
12
12
  {
13
13
  usage: 'usage: kit logout',
14
- about: "disconnects this computer, stops House Kit and ends your own agent's connection",
14
+ about: "disconnects this computer, stops the Kit process and ends your own agent's connection",
15
15
  operands: 0,
16
16
  run: logout,
17
17
  },
@@ -20,7 +20,7 @@ const COMMANDS = new Map([
20
20
  'resident',
21
21
  {
22
22
  usage: 'usage: kit resident',
23
- about: "keeps this Environment connected; House Kit's service runs it",
23
+ about: "keeps this Environment connected; Kit's service runs it",
24
24
  operands: 0,
25
25
  run: resident,
26
26
  },
package/dist/mcp.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { KIT_VERSION } from './clis.js';
2
+ import { KIT_RELEASE } from './clis.js';
3
3
  import { relayed } from './relay.js';
4
4
  const MCP_PROTOCOL_VERSION = '2026-07-28';
5
5
  export const UNREACHABLE = 'House is unreachable; call again in a minute';
6
+ export class Unsettled extends Error {
7
+ }
6
8
  export function rpc(method, params) {
7
9
  return { jsonrpc: '2.0', id: randomUUID(), method, params };
8
10
  }
@@ -13,7 +15,7 @@ export function mcpBody(message, operation) {
13
15
  ...message.params,
14
16
  _meta: {
15
17
  'io.modelcontextprotocol/protocolVersion': MCP_PROTOCOL_VERSION,
16
- 'io.modelcontextprotocol/clientInfo': { name: '@agentshouse/kit', version: KIT_VERSION },
18
+ 'io.modelcontextprotocol/clientInfo': { name: '@agentshouse/kit', version: KIT_RELEASE },
17
19
  'io.modelcontextprotocol/clientCapabilities': {},
18
20
  ...(operation === null ? {} : { 'agents.house/agent-operation': operation }),
19
21
  },
package/dist/placement.js CHANGED
@@ -18,9 +18,9 @@ export async function updatesItself() {
18
18
  // sudo answers at once, so ten seconds only bounds a stuck sudo.
19
19
  return (await run('sudo', ['-n', 'true'], 10_000)).status === 0;
20
20
  }
21
- export function installCommand() {
21
+ function recorded(name) {
22
22
  try {
23
- return readFileSync(join(kitHome(), 'install-command'), 'utf8').trim();
23
+ return readFileSync(join(kitHome(), name), 'utf8').trim();
24
24
  }
25
25
  catch (error) {
26
26
  if (error.code === 'ENOENT')
@@ -28,3 +28,12 @@ export function installCommand() {
28
28
  throw error;
29
29
  }
30
30
  }
31
+ export function installCommand() {
32
+ return recorded('install-command');
33
+ }
34
+ export function machine() {
35
+ const placed = recorded('placement');
36
+ if (placed === 'container' || placed === 'server' || placed === 'windows')
37
+ return placed;
38
+ return process.platform === 'darwin' ? 'macos' : 'linux';
39
+ }
package/dist/resident.js CHANGED
@@ -8,16 +8,17 @@ import { fileURLToPath } from 'node:url';
8
8
  import { Agents } from './agents.js';
9
9
  import { house } from './api.js';
10
10
  import { received } from './bridge.js';
11
- import { CONVERSATION_INPUTS, Conversations } from './conversations.js';
11
+ import { CONVERSATION_METHODS, Conversations } from './conversations.js';
12
12
  import { LocalCopies } from './copies.js';
13
13
  import { restart } from './end.js';
14
14
  import { agentBase, enrolled, forgetRestart, kitHome, ownerLock, ownerSocket, restartAsked } from './home.js';
15
+ import { Calls } from './calls.js';
15
16
  import { killDescendants } from './scope.js';
16
17
  import { quoted } from './shell.js';
17
18
  import { SignIns } from './sign-in.js';
18
19
  import { placeSkillSet } from './skills.js';
19
- import { holdStream } from './stream.js';
20
- import { Updates } from './update.js';
20
+ import { holdConnection } from './connection.js';
21
+ import { KitUpdate } from './update.js';
21
22
  import { codexLauncher } from './work.js';
22
23
  function report(error) {
23
24
  process.stderr.write(`kit: ${error instanceof Error ? error.message : String(error)}\n`);
@@ -46,7 +47,7 @@ async function serveOwner(copies) {
46
47
  if (!claimed(lock)) {
47
48
  lock.close();
48
49
  const running = await runningResident();
49
- throw new Error(`${running === null ? 'another Kit' : `Kit ${running}`} already runs this Environment from Kit home ${kitHome()}; stop it first to run another here`);
50
+ throw new Error(`${running === null ? 'another Kit process' : `Kit process ${running}`} already runs this Environment from Kit home ${kitHome()}; stop it first to run another Kit process here`);
50
51
  }
51
52
  const server = createServer(async (request, response) => {
52
53
  try {
@@ -131,9 +132,9 @@ export async function resident() {
131
132
  });
132
133
  process.env.HOUSE_KIT_RESIDENT = String(process.pid);
133
134
  const agents = new Agents(house, () => changed());
134
- let stream = null;
135
+ let connection = null;
135
136
  let reported = false;
136
- const idle = () => conversations.idle() && signIns.idle() && agents.idle();
137
+ const idle = () => calls.idle() && conversations.idle() && agents.idle();
137
138
  const changed = () => {
138
139
  if (!idle()) {
139
140
  reported = false;
@@ -146,15 +147,17 @@ export async function resident() {
146
147
  logged(house.deliver('/kit/idle', {}, idle));
147
148
  }
148
149
  };
150
+ const calls = new Calls(house, changed);
149
151
  const conversations = new Conversations({
150
152
  house,
151
153
  agents,
152
154
  copies,
153
- send: (frame) => stream?.send(frame) ?? false,
155
+ calls,
156
+ send: (notification) => connection?.send(notification) ?? false,
154
157
  changed,
155
158
  });
156
- const signIns = new SignIns(house, agents, changed);
157
- const updates = new Updates(house);
159
+ const signIns = new SignIns(house, agents, calls);
160
+ const update = new KitUpdate(calls);
158
161
  for (const signal of ['SIGTERM', 'SIGINT']) {
159
162
  process.once(signal, () => {
160
163
  killDescendants();
@@ -162,32 +165,33 @@ export async function resident() {
162
165
  });
163
166
  }
164
167
  await house.deliver('/kit/restarted', {}).catch(report);
165
- await updates.resumed().catch(report);
168
+ await update.resumed().catch(report);
166
169
  logged(agents.refresh());
167
- stream = holdStream({
170
+ connection = holdConnection({
168
171
  opened: () => {
169
172
  conversations.opened();
170
173
  changed();
171
174
  logged(copies.connected());
172
175
  },
173
- frame: (arrived) => {
174
- if (arrived.type === 'watching')
175
- conversations.watching(arrived.watching === true);
176
- if (arrived.type === 'work_available' && arrived.subject === 'agents')
177
- logged(agents.refresh());
178
- if (arrived.type === 'input') {
179
- reported = false;
180
- if (arrived.kind === 'sign_in')
181
- signIns.start(arrived);
182
- else if (arrived.kind === 'update')
183
- updates.start(arrived);
184
- else if (CONVERSATION_INPUTS.has(arrived.kind))
185
- conversations.input(arrived);
186
- else {
187
- const refused = { refused: `This Kit does not know ${arrived.kind}; update the Kit` };
188
- logged(house.deliver(`/kit/inputs/${arrived.input_id}/ack`, refused).finally(changed));
189
- }
176
+ call: (call) => {
177
+ if (call.kind === 'sign_in')
178
+ signIns.start(call);
179
+ else if (call.kind === 'update')
180
+ update.start(call);
181
+ else if (CONVERSATION_METHODS.has(call.kind))
182
+ conversations.call(call);
183
+ else {
184
+ const refused = { refused: `This Kit does not know ${call.kind}; update the Kit` };
185
+ void calls.carry(call.input_id, async () => ({ result: refused }));
190
186
  }
191
187
  },
188
+ notification: (notification) => {
189
+ if (notification.type === 'watching')
190
+ conversations.watching(notification.watching === true);
191
+ if (notification.type === 'authority_changed')
192
+ logged(copies.changed(notification.rooms));
193
+ if (notification.type === 'work_available' && notification.subject === 'agents')
194
+ logged(agents.refresh());
195
+ },
192
196
  });
193
197
  }
package/dist/scope.js CHANGED
@@ -4,6 +4,7 @@ import { join } from 'node:path';
4
4
  const LISTING_BYTES = 1 << 28;
5
5
  const CGROUPS = '/sys/fs/cgroup';
6
6
  const GONE = new Set(['ENOENT', 'ESRCH']);
7
+ const EXITED = new Set(['Z', 'X']);
7
8
  function failure(error) {
8
9
  return error.code ?? '';
9
10
  }
@@ -24,6 +25,8 @@ function read(pid) {
24
25
  try {
25
26
  const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
26
27
  const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
28
+ if (EXITED.has(fields[0]))
29
+ return null;
27
30
  const command = readFileSync(`/proc/${pid}/cmdline`, 'utf8').replaceAll('\0', ' ');
28
31
  return { parent: Number(fields[1]), session: fields[3], command };
29
32
  }
package/dist/service.js CHANGED
@@ -1,4 +1,7 @@
1
1
  import { spawnSync } from 'node:child_process';
2
+ import { existsSync, realpathSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+ import { kitHome } from './home.js';
2
5
  const UNIT = 'house-kit.service';
3
6
  // launchctl and systemctl answer at once, so ten seconds only bounds a stuck service manager.
4
7
  const MANAGER_MS = 10_000;
@@ -10,7 +13,21 @@ function start(command, args) {
10
13
  if (started.status === 0)
11
14
  return;
12
15
  const cause = started.error?.message ?? (started.stderr.trim() || `${command} exited with ${started.status ?? 'a signal'}`);
13
- throw new Error(`the House Kit service could not be started: ${cause}`);
16
+ throw new Error(`the Kit service could not be started: ${cause}`);
17
+ }
18
+ function physical(path) {
19
+ return existsSync(path) ? realpathSync(path) : resolve(path);
20
+ }
21
+ function systemdHome(environment) {
22
+ for (const [, quoted, bare] of environment.matchAll(/"((?:[^"\\]|\\.)*)"|(\S+)/g)) {
23
+ const assignment = bare ?? quoted.replace(/\\(.)/g, '$1');
24
+ if (assignment.startsWith('HOUSE_KIT_HOME='))
25
+ return assignment.slice('HOUSE_KIT_HOME='.length);
26
+ }
27
+ return undefined;
28
+ }
29
+ function runsThisHome(served) {
30
+ return served !== undefined && physical(served) === physical(kitHome());
14
31
  }
15
32
  export function startService() {
16
33
  if (process.platform === 'darwin') {
@@ -18,12 +35,17 @@ export function startService() {
18
35
  const printed = manage('launchctl', ['print', service]);
19
36
  if (printed.status !== 0 || /^\tstate = running$/m.test(printed.stdout))
20
37
  return false;
38
+ if (!runsThisHome(/^\t+HOUSE_KIT_HOME => (.+)$/m.exec(printed.stdout)?.[1]))
39
+ return false;
21
40
  start('launchctl', ['kickstart', service]);
22
41
  return true;
23
42
  }
24
43
  for (const scope of [['--user'], []]) {
25
44
  if (manage('systemctl', [...scope, 'is-enabled', '--quiet', UNIT]).status !== 0)
26
45
  continue;
46
+ const environment = manage('systemctl', [...scope, 'show', '--property=Environment', '--value', UNIT]).stdout;
47
+ if (!runsThisHome(systemdHome(environment)))
48
+ continue;
27
49
  if (manage('systemctl', [...scope, 'is-active', '--quiet', UNIT]).status === 0)
28
50
  return false;
29
51
  start('systemctl', [...scope, 'start', UNIT]);
package/dist/sign-in.js CHANGED
@@ -9,68 +9,50 @@ const CODE = /^[A-Z0-9]+-[A-Z0-9]+$/;
9
9
  function causeOf(error) {
10
10
  return error instanceof Error ? error.message : String(error);
11
11
  }
12
- function logged(error) {
13
- process.stderr.write(`kit: ${causeOf(error)}\n`);
14
- }
15
12
  function endOf(signal) {
16
13
  return new Promise((resolve) => signal.addEventListener('abort', () => resolve(true), { once: true }));
17
14
  }
18
15
  export class SignIns {
19
16
  house;
20
17
  agents;
21
- changed;
22
- running = new Set();
23
- constructor(house, agents, changed) {
18
+ calls;
19
+ constructor(house, agents, calls) {
24
20
  this.house = house;
25
21
  this.agents = agents;
26
- this.changed = changed;
27
- }
28
- idle() {
29
- return this.running.size === 0;
22
+ this.calls = calls;
30
23
  }
31
- start(input) {
32
- if (this.running.has(input.input_id))
33
- return;
34
- this.running.add(input.input_id);
35
- this.changed();
36
- this.signIn(input)
37
- .catch(logged)
38
- .finally(() => {
39
- this.running.delete(input.input_id);
40
- this.changed();
41
- });
24
+ start(call) {
25
+ void this.calls.carry(call.input_id, () => this.signIn(call));
42
26
  }
43
- async signIn(input) {
27
+ async signIn(call) {
44
28
  const ending = new AbortController();
45
29
  // House stops waiting for a sign-in ten minutes after it wrote it and sends the seconds left, so its install and login end with the page.
46
- const timer = setTimeout(() => ending.abort(), input.ends_in * 1000);
47
- const ended = await Promise.race([this.signedIn(input, ending.signal), endOf(ending.signal)]);
30
+ const timer = setTimeout(() => ending.abort(), call.ends_in * 1000);
31
+ const ended = await Promise.race([this.signedIn(call, ending.signal), endOf(ending.signal)]);
48
32
  clearTimeout(timer);
49
33
  if (ended)
50
- process.stderr.write(`kit: the ${input.cli} sign-in reached the end House set for it\n`);
51
- await this.house.deliver(`/kit/inputs/${input.input_id}/ack`, {});
52
- if (!ended)
53
- await this.agents.reread(input.cli);
34
+ process.stderr.write(`kit: the ${call.cli} sign-in reached the end House set for it\n`);
35
+ return ended ? { result: {} } : { result: {}, returned: () => this.agents.reread(call.cli) };
54
36
  }
55
- async signedIn(input, signal) {
56
- await this.login(input, signal);
37
+ async signedIn(call, signal) {
38
+ await this.login(call, signal);
57
39
  if (!signal.aborted)
58
- await this.agents.recheck(input.cli, signal);
40
+ await this.agents.recheck(call.cli, signal);
59
41
  return false;
60
42
  }
61
- async login(input, signal) {
43
+ async login(call, signal) {
62
44
  let cli;
63
45
  try {
64
- cli = await this.agents.located(input.cli, signal);
46
+ cli = await this.agents.located(call.cli, signal);
65
47
  }
66
48
  catch (error) {
67
49
  if (!signal.aborted)
68
- process.stderr.write(`kit: the ${input.cli} sign-in failed: ${causeOf(error)}\n`);
50
+ process.stderr.write(`kit: the ${call.cli} sign-in failed: ${causeOf(error)}\n`);
69
51
  return;
70
52
  }
71
53
  if (signal.aborted)
72
54
  return;
73
- const login = CLIS[input.cli].login;
55
+ const login = CLIS[call.cli].login;
74
56
  const child = spawn(cli, login.args, {
75
57
  stdio: ['pipe', 'pipe', 'pipe'],
76
58
  detached: true,
@@ -102,7 +84,7 @@ export class SignIns {
102
84
  return;
103
85
  held = true;
104
86
  const steps = [{ kind: 'visit', label: 'Open this link and sign in', url: link }, step];
105
- void holdSecretInput(this.house, input.input_id, steps, running).then((content) => {
87
+ void holdSecretInput(this.house, call.input_id, steps, running).then((content) => {
106
88
  if (!running())
107
89
  return;
108
90
  if (content === null)
@@ -119,6 +101,6 @@ export class SignIns {
119
101
  });
120
102
  signal.removeEventListener('abort', stop);
121
103
  if (failed !== null && !signal.aborted)
122
- process.stderr.write(`kit: the ${input.cli} sign-in failed: ${failed}\n`);
104
+ process.stderr.write(`kit: the ${call.cli} sign-in failed: ${failed}\n`);
123
105
  }
124
106
  }