@parlor/agent-host 0.3.0 → 0.3.2

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 (43) hide show
  1. package/dist/agent-options.d.ts +5 -0
  2. package/dist/agent-options.d.ts.map +1 -1
  3. package/dist/agent-options.js +32 -0
  4. package/dist/agent-options.js.map +1 -1
  5. package/dist/agent-skills.d.ts +15 -0
  6. package/dist/agent-skills.d.ts.map +1 -0
  7. package/dist/agent-skills.js +92 -0
  8. package/dist/agent-skills.js.map +1 -0
  9. package/dist/agent-survey.d.ts +15 -0
  10. package/dist/agent-survey.d.ts.map +1 -0
  11. package/dist/agent-survey.js +51 -0
  12. package/dist/agent-survey.js.map +1 -0
  13. package/dist/agents.d.ts +15 -0
  14. package/dist/agents.d.ts.map +1 -1
  15. package/dist/agents.js +33 -9
  16. package/dist/agents.js.map +1 -1
  17. package/dist/codex-app-server.d.ts +7 -0
  18. package/dist/codex-app-server.d.ts.map +1 -0
  19. package/dist/codex-app-server.js +46 -0
  20. package/dist/codex-app-server.js.map +1 -0
  21. package/dist/daemon.d.ts.map +1 -1
  22. package/dist/daemon.js +192 -26
  23. package/dist/daemon.js.map +1 -1
  24. package/dist/learned.d.ts +2 -0
  25. package/dist/learned.d.ts.map +1 -1
  26. package/dist/learned.js +1 -0
  27. package/dist/learned.js.map +1 -1
  28. package/dist/link.d.ts +5 -1
  29. package/dist/link.d.ts.map +1 -1
  30. package/dist/link.js +25 -1
  31. package/dist/link.js.map +1 -1
  32. package/dist/mcp-proxy.d.ts.map +1 -1
  33. package/dist/mcp-proxy.js +109 -17
  34. package/dist/mcp-proxy.js.map +1 -1
  35. package/dist/shell-path.d.ts +2 -0
  36. package/dist/shell-path.d.ts.map +1 -1
  37. package/dist/shell-path.js +14 -3
  38. package/dist/shell-path.js.map +1 -1
  39. package/dist/spec-kit.d.ts +4 -0
  40. package/dist/spec-kit.d.ts.map +1 -1
  41. package/dist/spec-kit.js +13 -1
  42. package/dist/spec-kit.js.map +1 -1
  43. package/package.json +9 -9
package/dist/daemon.js CHANGED
@@ -3,8 +3,10 @@ import { createServer } from "node:net";
3
3
  import { basename, delimiter, dirname, join } from "node:path";
4
4
  import { HOST_PROTOCOL, } from "@parlor/chat";
5
5
  import { ActionError, asId, DEV_USER_ID } from "@parlor/core";
6
+ import { removeAgentSkills, writeAgentSkills } from "./agent-skills.js";
7
+ import { readUsage, surveyOptions } from "./agent-survey.js";
6
8
  import { agentNames, registerAgentTools, unregisterAgentTools, } from "./agent-tools.js";
7
- import { detectAgents, loadProfiles } from "./agents.js";
9
+ import { findAgents, loadProfiles } from "./agents.js";
8
10
  import { connectControl } from "./control.js";
9
11
  import { requestDevHostToken } from "./dev-host.js";
10
12
  import { devHostKeys } from "./grants.js";
@@ -21,7 +23,7 @@ import { compareVersions, RELEASE_KEY, signedRelease } from "./release.js";
21
23
  import { revokeGrant } from "./revoke.js";
22
24
  import { selfEntry } from "./self.js";
23
25
  import { installLoginItem, installRelease } from "./service.js";
24
- import { agentEnvironment } from "./shell-path.js";
26
+ import { agentEnvironment, rereadAgentPath } from "./shell-path.js";
25
27
  import { runTurn } from "./turn.js";
26
28
  const CONNECT_TIMEOUT_MS = 15_000;
27
29
  /** An agent token is asked for again this long before it runs out. */
@@ -63,12 +65,16 @@ export class HostAlreadyRunning extends Error {
63
65
  super("Parlor Host is already running on this computer.");
64
66
  }
65
67
  }
68
+ /** Codex's own CLI throttles its rate-limit reports on the same interval. */
69
+ const USAGE_READ_FLOOR_MS = 30_000;
66
70
  export function createHostDaemon(deps) {
67
71
  const { paths } = deps;
68
72
  const links = new Map();
69
73
  const devTokens = new Map();
70
74
  const name = computerName();
71
75
  const platform = platformName();
76
+ /** As `agents.json` lists them; `profiles` are these with each `binary` set to the CLI found. */
77
+ let listedProfiles = [];
72
78
  let profiles = [];
73
79
  let agents = [];
74
80
  const learned = learnedStore(paths.learned);
@@ -89,14 +95,75 @@ export function createHostDaemon(deps) {
89
95
  return undefined;
90
96
  }
91
97
  };
98
+ /** What an agent said about itself, told to every linked app when it changes what they show. */
99
+ const learn = (agentId, news) => {
100
+ void learned
101
+ .learn(agentId, news)
102
+ .then((changed) => {
103
+ if (!changed)
104
+ return;
105
+ for (const linked of links.values())
106
+ void report(linked).catch(() => undefined);
107
+ })
108
+ .catch(() => undefined);
109
+ };
110
+ /** Ends the reads below on stop or handover, so nothing outlives the daemon. */
111
+ const stopping = new AbortController();
112
+ const usageReadAt = new Map();
113
+ /** The allowance from the agent's own CLI where ACP carries none, at most every 30s per agent. */
114
+ const refreshUsage = async (agentId) => {
115
+ const profile = profiles.find((candidate) => candidate.id === agentId);
116
+ const now = Date.now();
117
+ if (!profile || stopping.signal.aborted)
118
+ return;
119
+ if (now - (usageReadAt.get(agentId) ?? 0) < USAGE_READ_FLOOR_MS)
120
+ return;
121
+ usageReadAt.set(agentId, now);
122
+ const usage = await readUsage(profile, await agentEnvironment(), now).catch(() => undefined);
123
+ if (usage && !stopping.signal.aborted)
124
+ learn(agentId, { usage });
125
+ };
126
+ let surveyed = false;
127
+ /**
128
+ * Options for every agent that has not run a turn yet, so the sidebar can offer a model first.
129
+ * Once per run, when an app first links: a computer no app uses never starts its agents.
130
+ */
131
+ const surveyAgents = async () => {
132
+ if (surveyed)
133
+ return;
134
+ surveyed = true;
135
+ const env = await agentEnvironment();
136
+ for (const agent of agents) {
137
+ const profile = profiles.find((candidate) => candidate.id === agent.id);
138
+ if (!profile || stopping.signal.aborted)
139
+ continue;
140
+ void refreshUsage(agent.id);
141
+ if (learned.knows(agent.id))
142
+ continue;
143
+ const options = await surveyOptions(profile, deps.launcher, env, stopping.signal);
144
+ if (stopping.signal.aborted)
145
+ return;
146
+ logEvent("agent_surveyed", { agentId: agent.id, options: options?.length ?? 0 });
147
+ if (options && options.length > 0)
148
+ learn(agent.id, { options });
149
+ }
150
+ };
92
151
  const running = new Set();
93
152
  let handedOver = false;
94
153
  let finishing = false;
95
154
  let finishHandover = () => undefined;
96
155
  const drained = new Promise((resolve) => (finishHandover = resolve));
97
- const run = (state, claimed) => {
156
+ /** A turn runs once here, even when a pushed turn its app took back is claimed again. */
157
+ const runningTurns = new Set();
158
+ const run = (state, claimed, how = "claimed") => {
98
159
  const { link } = state;
99
- logEvent("turn_claimed", { appOrigin: link.grant.appOrigin, turnId: claimed.turnId });
160
+ if (runningTurns.has(claimed.turnId))
161
+ return;
162
+ runningTurns.add(claimed.turnId);
163
+ logEvent(how === "pushed" ? "turn_pushed" : "turn_claimed", {
164
+ appOrigin: link.grant.appOrigin,
165
+ turnId: claimed.turnId,
166
+ });
100
167
  state.turns += 1;
101
168
  const turn = (link.grant.dev ? Promise.resolve(DEV_POLICY) : loadPolicy(paths.policy, deps.policy))
102
169
  .then((policy) => runTurn(claimed, {
@@ -119,17 +186,7 @@ export function createHostDaemon(deps) {
119
186
  policy,
120
187
  prompter: deps.prompter,
121
188
  now: Date.now,
122
- learned: (agentId, news) => {
123
- void learned
124
- .learn(agentId, news)
125
- .then((changed) => {
126
- if (!changed)
127
- return;
128
- for (const linked of links.values())
129
- void report(linked).catch(() => undefined);
130
- })
131
- .catch(() => undefined);
132
- },
189
+ learned: learn,
133
190
  pairing: {
134
191
  secret: async () => (await deps.grants.get(link.grant.appOrigin))?.pairingSecret,
135
192
  forget: async () => {
@@ -143,6 +200,8 @@ export function createHostDaemon(deps) {
143
200
  }))
144
201
  .then((outcome) => logEvent("turn_finished", { turnId: claimed.turnId, outcome }), (error) => logEvent("turn_finished", { turnId: claimed.turnId, error: String(error) }))
145
202
  .finally(() => {
203
+ runningTurns.delete(claimed.turnId);
204
+ void refreshUsage(claimed.agent.agentId);
146
205
  state.turns -= 1;
147
206
  running.delete(turn);
148
207
  settleHandover();
@@ -190,21 +249,112 @@ export function createHostDaemon(deps) {
190
249
  agents: agentNames(profiles, { name: tools.name, agents: tools.agents }),
191
250
  };
192
251
  };
252
+ /** The skill that sends the agents to the app's tools when the user names it, kept current. */
253
+ const writeSkills = async (grant) => {
254
+ const tools = grant.agentTools;
255
+ if (grant.dev || !tools?.on || !tools.name || !tools.agents?.length)
256
+ return;
257
+ await writeAgentSkills(tools.agents, {
258
+ name: tools.name,
259
+ appName: grant.appName,
260
+ appOrigin: grant.appOrigin,
261
+ account: grant.account.email ?? grant.account.name,
262
+ }, await agentEnvironment());
263
+ };
264
+ let toolsWork = Promise.resolve();
265
+ /** Registering and removing the app with the agents runs one at a time, on the grant as saved by then. */
266
+ const serially = (work) => {
267
+ const next = toolsWork.then(work, work);
268
+ toolsWork = next.catch(() => undefined);
269
+ return next;
270
+ };
193
271
  /** Registers the app with the agents here once the user chose it and it is not yet. */
194
- const ensureAgentTools = async (grant) => {
195
- if (!grant.agentTools?.on || grant.dev || grant.agentTools.agents)
272
+ const ensureAgentTools = (grant) => serially(() => ensureAgentToolsNow(grant));
273
+ const ensureAgentToolsNow = async (grant) => {
274
+ if (!grant.agentTools?.on || grant.dev)
275
+ return grant;
276
+ if (grant.agentTools.agents) {
277
+ await writeSkills(grant);
196
278
  return grant;
279
+ }
197
280
  const registered = await registerAgentTools(toolsDeps(), grant);
198
281
  const next = { ...grant, agentTools: { on: true, ...registered } };
199
282
  await deps.grants.save(next);
283
+ await writeSkills(next);
200
284
  return next;
201
285
  };
202
- const removeAgentTools = async (grant) => {
286
+ /** Hands the app to agents installed since it was registered with the ones here then. */
287
+ const handToNewAgents = (origin) => serially(async () => {
288
+ const grant = await deps.grants.get(origin);
289
+ if (grant)
290
+ await handToNewAgentsNow(grant);
291
+ });
292
+ const handToNewAgentsNow = async (grant) => {
293
+ const tools = grant.agentTools;
294
+ if (grant.declined || grant.dev || !tools?.on || !tools.agents)
295
+ return;
296
+ const have = new Set(tools.agents);
297
+ const fresh = toolsDeps().profiles.filter((profile) => !have.has(profile.id));
298
+ if (fresh.length === 0) {
299
+ await writeSkills(grant);
300
+ return;
301
+ }
302
+ const registered = await registerAgentTools({ ...toolsDeps(), profiles: fresh }, grant);
303
+ if (registered.agents.length === 0) {
304
+ await writeSkills(grant);
305
+ return;
306
+ }
307
+ const next = {
308
+ ...grant,
309
+ agentTools: {
310
+ on: true,
311
+ name: registered.name,
312
+ agents: [...tools.agents, ...registered.agents],
313
+ },
314
+ };
315
+ await deps.grants.save(next);
316
+ latest.set(grant.appOrigin, next);
317
+ await writeSkills(next);
318
+ };
319
+ let checking;
320
+ let checkAgain = false;
321
+ /**
322
+ * Looks for the agents again because a tab asked: one installed since this computer
323
+ * started shows in every app's sidebar, and gets the apps the user let its agents use.
324
+ */
325
+ const checkAgents = () => {
326
+ if (checking) {
327
+ checkAgain = true;
328
+ return checking;
329
+ }
330
+ checking = (async () => {
331
+ do {
332
+ checkAgain = false;
333
+ await rereadAgentPath();
334
+ const found = await findAgents(listedProfiles);
335
+ profiles = found.profiles;
336
+ agents = [...found.agents];
337
+ for (const grant of await deps.grants.list()) {
338
+ await handToNewAgents(grant.appOrigin).catch((error) => logEvent("agent_tools_failed", {
339
+ appOrigin: grant.appOrigin,
340
+ error: String(error),
341
+ }));
342
+ }
343
+ await Promise.all([...links.values()].map((state) => report(state, true).catch(() => undefined)));
344
+ logEvent("agents_checked", { agents: agents.map((agent) => agent.id) });
345
+ } while (checkAgain);
346
+ })().finally(() => {
347
+ checking = undefined;
348
+ });
349
+ return checking;
350
+ };
351
+ const removeAgentTools = (grant) => serially(async () => {
203
352
  const tools = grant?.agentTools;
204
353
  if (!tools?.name || !tools.agents?.length)
205
354
  return;
206
355
  await unregisterAgentTools(toolsDeps(), { name: tools.name, agents: tools.agents });
207
- };
356
+ await removeAgentSkills(tools.agents, tools.name, await agentEnvironment());
357
+ });
208
358
  const agentTokens = new Map();
209
359
  /** The grant as last saved, which a link opened before a change still holds the old copy of. */
210
360
  const latest = new Map();
@@ -256,6 +406,7 @@ export function createHostDaemon(deps) {
256
406
  await deps.pool.forget(grant);
257
407
  };
258
408
  const open = (grant, keys) => {
409
+ void surveyAgents().catch(() => undefined);
259
410
  const origin = grant.appOrigin;
260
411
  latest.set(origin, grant);
261
412
  let state;
@@ -266,6 +417,14 @@ export function createHostDaemon(deps) {
266
417
  platform,
267
418
  fetch: (input, init) => fetch(input, init),
268
419
  onWake: () => wake(state),
420
+ onAgentsCheck: () => void checkAgents(),
421
+ onPushed: (_link, turn) => {
422
+ // Tells the app this computer has the turn, while it already starts on it.
423
+ void link.api.calls
424
+ .call("hostAppend", { turnId: turn.turnId, items: [] })
425
+ .catch(() => undefined);
426
+ run(state, turn, "pushed");
427
+ },
269
428
  busy: () => state.turns > 0,
270
429
  onRevoked: () => {
271
430
  if (links.get(origin) !== state)
@@ -320,11 +479,13 @@ export function createHostDaemon(deps) {
320
479
  ? { ...agent, outsideName: tools.name }
321
480
  : agent);
322
481
  };
323
- const report = async (state) => {
482
+ /** `agentsChecked` when the agents were just looked for, which a tab waiting on a check reads. */
483
+ const report = async (state, agentsChecked = false) => {
324
484
  const reported = state.link.api.calls.call("hostReport", {
325
485
  online: true,
326
486
  agents: describeFor(state.link.grant.appOrigin),
327
487
  ...(await pinnedDevices(state)),
488
+ ...(agentsChecked ? { agentsChecked: true } : {}),
328
489
  });
329
490
  const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error("The app did not answer in time.")), CONNECT_TIMEOUT_MS));
330
491
  try {
@@ -352,7 +513,7 @@ export function createHostDaemon(deps) {
352
513
  const attach = async (origin, hostToken) => {
353
514
  await closeLink(origin, "replaced");
354
515
  devTokens.set(origin, hostToken);
355
- await report(open(await devGrant(origin), devHostKeys(hostToken)));
516
+ await report(open(await devGrant(origin), devHostKeys(hostToken)), true);
356
517
  };
357
518
  const reattach = async (grant) => {
358
519
  await closeLink(grant.appOrigin, "dev_server_restarted");
@@ -407,8 +568,10 @@ export function createHostDaemon(deps) {
407
568
  openHostLog(join(paths.root, "logs", "daemon.log"));
408
569
  await putHostCommandFirstOnPath(paths.root);
409
570
  await deps.proxy.listen(paths.proxy);
410
- profiles = await loadProfiles(paths.agents);
411
- agents = await detectAgents(profiles);
571
+ listedProfiles = await loadProfiles(paths.agents);
572
+ const found = await findAgents(listedProfiles);
573
+ profiles = found.profiles;
574
+ agents = [...found.agents];
412
575
  await learned.load();
413
576
  finishStartup();
414
577
  }
@@ -423,7 +586,8 @@ export function createHostDaemon(deps) {
423
586
  for (const grant of await deps.grants.list()) {
424
587
  if (grant.declined)
425
588
  continue;
426
- void report(open(grant, deps.keys)).catch((error) => logEvent("link_failed", { appOrigin: grant.appOrigin, error: String(error) }));
589
+ void handToNewAgents(grant.appOrigin).catch(() => undefined);
590
+ void report(open(grant, deps.keys), true).catch((error) => logEvent("link_failed", { appOrigin: grant.appOrigin, error: String(error) }));
427
591
  }
428
592
  },
429
593
  async connect(appOrigin) {
@@ -435,14 +599,14 @@ export function createHostDaemon(deps) {
435
599
  const current = links.get(appOrigin);
436
600
  if (current?.link.grant.grantId === grant.grantId) {
437
601
  latest.set(appOrigin, grant);
438
- await report(current);
602
+ await report(current, true);
439
603
  return toolsOf(grant);
440
604
  }
441
605
  await closeLink(appOrigin, "replaced");
442
606
  if (current)
443
607
  await deps.pool.forget(current.link.grant);
444
608
  latest.set(appOrigin, grant);
445
- await report(open(grant, deps.keys));
609
+ await report(open(grant, deps.keys), true);
446
610
  return toolsOf(grant);
447
611
  },
448
612
  async agentTools(appOrigin, on) {
@@ -485,6 +649,7 @@ export function createHostDaemon(deps) {
485
649
  if (handedOver)
486
650
  return;
487
651
  handedOver = true;
652
+ stopping.abort();
488
653
  for (const state of links.values())
489
654
  state.link.drain();
490
655
  await Promise.all([...links.values()].map((state) => state.claiming));
@@ -495,6 +660,7 @@ export function createHostDaemon(deps) {
495
660
  logEvent("handover_started", { version: deps.release.version });
496
661
  },
497
662
  async stop() {
663
+ stopping.abort();
498
664
  if (!handedOver) {
499
665
  await Promise.all([...links.values()].map((state) => Promise.race([
500
666
  state.link.api.calls.call("hostReport", {