@jbarket/loomai 0.9.1 → 0.10.0

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/CHANGELOG.md CHANGED
@@ -12,6 +12,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.10.0] - 2026-10-24
16
+
17
+ ### Security
18
+
19
+ - **`harness_init` could write any file the daemon's user can.** Its
20
+ `target` was a free path and the harness name could carry newlines, so any
21
+ MCP caller could append to another agent's `IDENTITY.md` or plant a command
22
+ in a shell rc file. Over HTTP the target is now refused: it names a file on
23
+ the server, never the caller's. Locally it must be a harness instruction
24
+ file (`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`, …), judged at its real path.
25
+ Names refuse control characters, and managed-block writes use a random,
26
+ exclusive temp file. Found by the security review of the household work.
27
+
28
+ ### Added
29
+
30
+ - **The household, phase 1: one loom, several agents**
31
+ (docs/specs/household.md).
32
+ - `$LOOM_HOME/household.json` lists the agents and the default, and
33
+ `tokens.json` holds every token by sha256 with a label and a role.
34
+ - **Roles:** `agent:<name>` is MCP and `/cli` as that agent's stack only.
35
+ `owner` gets the default agent, another at `/agents/<name>/`, and the
36
+ page. `peer:<name>` gets knowledge tools only.
37
+ - **On every request** the daemon resolves the role and builds the
38
+ session's server over that agent's context dir. A session stays pinned
39
+ to the role and agent it opened with, and a revoke takes effect on the
40
+ next request.
41
+ - **New commands:** `loom household init|add|show` and
42
+ `loom token create|list|revoke`.
43
+ - **Nothing changes for one agent:** a plain context dir is a household of
44
+ one and behaves exactly as before.
45
+
46
+ ## [0.9.2] - 2026-10-24
47
+
48
+ ### Fixed
49
+
50
+ - **Logging in to the page (and every form on it) failed in a browser with
51
+ "forbidden: cross-site request".** The page was served with
52
+ `Referrer-Policy: no-referrer`, under which browsers send `Origin: null` on
53
+ the page's own form posts, and the cross-site guard refuses that. It's
54
+ `same-origin` now, which still sends nothing to links that leave the page.
55
+
15
56
  ## [0.9.1] - 2026-10-24
16
57
 
17
58
  ### Changed
@@ -656,7 +697,9 @@ Initial public release.
656
697
  stack and all added external-service dependencies or operational
657
698
  overhead.
658
699
 
659
- [Unreleased]: https://github.com/sleepunit-agents/loom/compare/v0.9.1...HEAD
700
+ [Unreleased]: https://github.com/sleepunit-agents/loom/compare/v0.10.0...HEAD
701
+ [0.10.0]: https://github.com/sleepunit-agents/loom/compare/v0.9.2...v0.10.0
702
+ [0.9.2]: https://github.com/sleepunit-agents/loom/compare/v0.9.1...v0.9.2
660
703
  [0.9.1]: https://github.com/sleepunit-agents/loom/compare/v0.9.0...v0.9.1
661
704
  [0.9.0]: https://github.com/sleepunit-agents/loom/compare/v0.8.0...v0.9.0
662
705
  [0.8.0]: https://github.com/sleepunit-agents/loom/compare/v0.7.0...v0.8.0
package/README.md CHANGED
@@ -648,6 +648,34 @@ loom tasks ready # → loom-tasks ready
648
648
  The rules, the dispatch details and how pieces compose:
649
649
  [`docs/extensions.md`](docs/extensions.md).
650
650
 
651
+ ### The household — several agents on one loom
652
+
653
+ One loom can serve several agents, with a shared commons
654
+ ([`docs/specs/household.md`](docs/specs/household.md)). A household is a
655
+ directory (`$LOOM_HOME`, default `~/.config/loom`) with a `household.json`,
656
+ a `tokens.json`, and one context dir per agent under `agents/`. A plain
657
+ context dir with no household around it is a household of one and works
658
+ exactly as before.
659
+
660
+ ```bash
661
+ loom household init art # ~/.config/loom/art → agents/art (a symlink stays)
662
+ loom household add mark # an agent whose dir already exists under agents/
663
+ loom token create --agent art --label "Art · house on wunder" # printed once
664
+ loom token create --owner --label "Jonathan · page"
665
+ loom token list
666
+ loom token revoke "Art · house on wunder"
667
+ ```
668
+
669
+ Every token has a role. **`agent:<name>`** is MCP and `/cli` as that agent:
670
+ its own stack only. **`owner`** gets the default agent, or another at
671
+ `/agents/<name>/` on the daemon, and the page. **`peer:<name>`** gets
672
+ knowledge tools only. The daemon resolves the role on every request; no tool
673
+ takes an agent argument, so a body can only be the agent its token names.
674
+ `LOOM_BEARER_TOKEN`, when set, is an owner token too. A daemon whose context
675
+ dir is `<home>/agents/<name>` next to a `household.json` serves the whole
676
+ household. A process with `LOOM_HOME` set and no context dir uses
677
+ `LOOM_AGENT`, or the household's default.
678
+
651
679
  ### Harness manifests and self-describe
652
680
 
653
681
  A **harness** is the MCP-capable runtime the agent runs in (Claude Code,
package/dist/cli/args.js CHANGED
@@ -5,9 +5,11 @@
5
5
  */
6
6
  import { resolve } from 'node:path';
7
7
  import { resolveDefaultContextPath } from '../config.js';
8
+ import { contextFromHome } from '../household.js';
8
9
  export function resolveEnv(flags, processEnv) {
9
10
  const contextDir = flags.contextDir ??
10
11
  processEnv.LOOM_CONTEXT_DIR ??
12
+ contextFromHome(processEnv) ??
11
13
  resolveDefaultContextPath(processEnv.HOME);
12
14
  return {
13
15
  contextDir: resolve(contextDir),
@@ -0,0 +1,3 @@
1
+ import type { IOStreams } from './io.js';
2
+ export declare function runHousehold(argv: string[], io: IOStreams): Promise<number>;
3
+ export declare function runToken(argv: string[], io: IOStreams): Promise<number>;
@@ -0,0 +1,139 @@
1
+ /**
2
+ * `loom household` and `loom token` (docs/specs/household.md). Local only:
3
+ * neither is a remote verb, so they never run through /cli. They read and
4
+ * write $LOOM_HOME (default ~/.config/loom) directly.
5
+ */
6
+ import { addAgent, createToken, householdHome, HouseholdError, initHousehold, loadHousehold, loadTokens, parseRole, revokeToken, } from '../household.js';
7
+ const HOUSEHOLD_USAGE = `Usage: loom household <command>
8
+
9
+ show The household at $LOOM_HOME: agents, the default, the owner
10
+ init <agent> [--owner <name>]
11
+ Make $LOOM_HOME/<agent> (a context dir) the first agent of a new
12
+ household: it moves to agents/<agent>, and a symlink stays behind
13
+ add <agent> Add an agent whose directory already exists under agents/
14
+
15
+ $LOOM_HOME defaults to ~/.config/loom.
16
+ `;
17
+ const TOKEN_USAGE = `Usage: loom token <command>
18
+
19
+ create (--agent <name> | --owner | --peer <name>) --label <text>
20
+ Mint a token. It's printed once; only its sha256 is kept
21
+ list Every token: label, role, created, last used, revoked
22
+ revoke <label> Revoke the live token with that label (takes effect on the next request)
23
+
24
+ Roles: agent:<name> is MCP and the CLI as that agent; owner sees everything and
25
+ the page; peer:<name> reaches commons knowledge only.
26
+ `;
27
+ function flag(args, name) {
28
+ const i = args.indexOf(name);
29
+ return i === -1 ? undefined : args[i + 1];
30
+ }
31
+ function need(io) {
32
+ const home = householdHome(io.env);
33
+ const h = loadHousehold(home);
34
+ if (!h)
35
+ io.stderr(`No household at ${home} (no household.json). Make one with: loom household init <agent>\n`);
36
+ return h;
37
+ }
38
+ export async function runHousehold(argv, io) {
39
+ const [sub, ...rest] = argv;
40
+ try {
41
+ if (sub === 'show') {
42
+ const h = need(io);
43
+ if (!h)
44
+ return 1;
45
+ io.stdout(`home ${h.home}\nagents ${h.agents.join(', ')}\ndefault ${h.default}\n${h.owner ? `owner ${h.owner}\n` : ''}`);
46
+ return 0;
47
+ }
48
+ if (sub === 'init') {
49
+ const agent = rest[0];
50
+ if (!agent || agent.startsWith('-')) {
51
+ io.stderr(HOUSEHOLD_USAGE);
52
+ return 2;
53
+ }
54
+ const h = initHousehold(householdHome(io.env), agent, { owner: flag(rest, '--owner') });
55
+ io.stdout(`Household at ${h.home}: ${agent} moved to agents/${agent}; ${h.home}/${agent} is now a symlink to it.\n`);
56
+ return 0;
57
+ }
58
+ if (sub === 'add') {
59
+ const h = need(io);
60
+ if (!h)
61
+ return 1;
62
+ const agent = rest[0];
63
+ if (!agent) {
64
+ io.stderr(HOUSEHOLD_USAGE);
65
+ return 2;
66
+ }
67
+ const next = addAgent(h, agent);
68
+ io.stdout(`Agents: ${next.agents.join(', ')}\n`);
69
+ return 0;
70
+ }
71
+ io.stderr(HOUSEHOLD_USAGE);
72
+ return sub === undefined || sub === '--help' || sub === '-h' ? 0 : 2;
73
+ }
74
+ catch (err) {
75
+ if (err instanceof HouseholdError) {
76
+ io.stderr(`${err.message}\n`);
77
+ return 1;
78
+ }
79
+ throw err;
80
+ }
81
+ }
82
+ export async function runToken(argv, io) {
83
+ const [sub, ...rest] = argv;
84
+ try {
85
+ if (sub === 'create') {
86
+ const h = need(io);
87
+ if (!h)
88
+ return 1;
89
+ const label = flag(rest, '--label');
90
+ const picked = [flag(rest, '--agent') && `agent:${flag(rest, '--agent')}`, rest.includes('--owner') && 'owner', flag(rest, '--peer') && `peer:${flag(rest, '--peer')}`].filter(Boolean);
91
+ const role = picked.length === 1 ? parseRole(picked[0]) : null;
92
+ if (!label || !role) {
93
+ io.stderr(TOKEN_USAGE);
94
+ return 2;
95
+ }
96
+ const { token, record } = createToken(h, { role, label });
97
+ // The token goes to stdout alone, so it can be piped straight into a file.
98
+ io.stderr(`Created ${record.role} token "${record.label}". It won't be shown again.\n`);
99
+ io.stdout(`${token}\n`);
100
+ return 0;
101
+ }
102
+ if (sub === 'list') {
103
+ const h = need(io);
104
+ if (!h)
105
+ return 1;
106
+ const tokens = loadTokens(h.home);
107
+ if (!tokens.length) {
108
+ io.stdout('No tokens yet.\n');
109
+ return 0;
110
+ }
111
+ for (const t of tokens) {
112
+ io.stdout(`${t.revoked ? 'revoked' : 'live '} ${t.role.padEnd(16)} ${t.label} (created ${t.created.slice(0, 10)}${t.last_used ? `, last used ${t.last_used.slice(0, 16).replace('T', ' ')}` : ', never used'}${t.revoked ? `, revoked ${t.revoked.slice(0, 10)}` : ''})\n`);
113
+ }
114
+ return 0;
115
+ }
116
+ if (sub === 'revoke') {
117
+ const h = need(io);
118
+ if (!h)
119
+ return 1;
120
+ const label = rest.join(' ').trim();
121
+ if (!label) {
122
+ io.stderr(TOKEN_USAGE);
123
+ return 2;
124
+ }
125
+ const t = revokeToken(h, label);
126
+ io.stdout(`Revoked ${t.role} token "${t.label}".\n`);
127
+ return 0;
128
+ }
129
+ io.stderr(TOKEN_USAGE);
130
+ return sub === undefined || sub === '--help' || sub === '-h' ? 0 : 2;
131
+ }
132
+ catch (err) {
133
+ if (err instanceof HouseholdError) {
134
+ io.stderr(`${err.message}\n`);
135
+ return 1;
136
+ }
137
+ throw err;
138
+ }
139
+ }
package/dist/cli/index.js CHANGED
@@ -32,6 +32,8 @@ Commands:
32
32
  doctor Probe the loom environment (read-only)
33
33
  migrate Apply pending schema migrations to memories.db
34
34
  pieces List installed pieces (loom-<name> on PATH)
35
+ household The household: agents and the default (docs/specs/household.md)
36
+ token Mint, list and revoke the household's tokens
35
37
 
36
38
  Pieces: \`loom <name> ...\` runs an installed extension, loom-<name> on PATH
37
39
  (e.g. loom tasks list). Core commands always win. See docs/extensions.md.
@@ -198,6 +200,14 @@ async function dispatch(sub, rest, io) {
198
200
  const { runPiecesCommand } = await import('./pieces.js');
199
201
  return runPiecesCommand(rest, io);
200
202
  }
203
+ case 'household': {
204
+ const { runHousehold } = await import('./household.js');
205
+ return runHousehold(rest, io);
206
+ }
207
+ case 'token': {
208
+ const { runToken } = await import('./household.js');
209
+ return runToken(rest, io);
210
+ }
201
211
  default:
202
212
  io.stderr(`Subcommand not implemented yet: ${sub}\n`);
203
213
  return 2;
package/dist/cli/serve.js CHANGED
@@ -17,6 +17,7 @@ import { parseArgs } from 'node:util';
17
17
  import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
18
18
  import { createLoomServer } from '../server.js';
19
19
  import { createKnowledgeOnlyServer } from '../transport/knowledge-server.js';
20
+ import { householdOf } from '../household.js';
20
21
  import { startHttpServer } from '../transport/http-server.js';
21
22
  import { resolveContextDir } from '../config.js';
22
23
  const USAGE = `Usage: loom serve [options]
@@ -140,9 +141,15 @@ export async function run(argv, io) {
140
141
  const host = values.host ?? process.env.LOOM_HTTP_HOST ?? '127.0.0.1';
141
142
  const port = Number(values.port ?? process.env.LOOM_HTTP_PORT ?? 8787);
142
143
  const token = process.env.LOOM_BEARER_TOKEN || undefined;
143
- const handle = await startHttpServer({ contextDir, host, port, token, gapsDir });
144
+ // A context dir at <home>/agents/<name> next to a household.json serves
145
+ // the whole household (docs/specs/household.md); anything else is a
146
+ // household of one, exactly as before.
147
+ const found = householdOf(contextDir);
148
+ const handle = await startHttpServer({ contextDir, host, port, token, gapsDir, household: found?.household });
144
149
  io.stderr(`loom: HTTP MCP daemon on http://${handle.host}:${handle.port} ` +
145
- `(${token ? 'bearer-gated' : 'open — network is the boundary'})\n`);
150
+ (found
151
+ ? `(household of ${found.household.agents.join(', ')}; tokens in ${found.household.home}/tokens.json)\n`
152
+ : `(${token ? 'bearer-gated' : 'open — network is the boundary'})\n`));
146
153
  // Hold open until the process is signalled.
147
154
  await new Promise(() => { });
148
155
  return 0;
@@ -3,5 +3,5 @@
3
3
  * (src/cli/index.ts) and the CLI-vs-MCP routing gate (src/index.ts) so
4
4
  * the set never drifts between entry points.
5
5
  */
6
- export declare const SUBCOMMANDS: readonly ['wake', 'recall', 'remember', 'forget', 'update', 'memory', 'knowledge', 'person', 'update-identity', 'bootstrap', 'serve', 'inject', 'harness', 'install', 'doctor', 'migrate', 'pieces'];
6
+ export declare const SUBCOMMANDS: readonly ['wake', 'recall', 'remember', 'forget', 'update', 'memory', 'knowledge', 'person', 'update-identity', 'bootstrap', 'serve', 'inject', 'harness', 'install', 'doctor', 'migrate', 'pieces', 'household', 'token'];
7
7
  export type Subcommand = typeof SUBCOMMANDS[number];
@@ -7,5 +7,5 @@ export const SUBCOMMANDS = [
7
7
  'wake', 'recall', 'remember', 'forget', 'update',
8
8
  'memory', 'knowledge', 'person', 'update-identity', 'bootstrap', 'serve',
9
9
  'inject', 'harness',
10
- 'install', 'doctor', 'migrate', 'pieces',
10
+ 'install', 'doctor', 'migrate', 'pieces', 'household', 'token',
11
11
  ];
package/dist/config.js CHANGED
@@ -18,6 +18,7 @@ import { resolve, dirname, basename } from 'node:path';
18
18
  import { homedir } from 'node:os';
19
19
  import { fileURLToPath } from 'node:url';
20
20
  import { existsSync, readFileSync, writeFileSync, statSync, realpathSync } from 'node:fs';
21
+ import { contextFromHome } from './household.js';
21
22
  /**
22
23
  * The single canonical helper for the default context path. Every call
23
24
  * site that needs `~/.config/loom/default` must go through this —
@@ -34,6 +35,10 @@ export function resolveContextDir() {
34
35
  if (argIdx !== -1 && process.argv[argIdx + 1]) {
35
36
  return resolve(process.argv[argIdx + 1]);
36
37
  }
38
+ // LOOM_HOME with a household.json: the agent LOOM_AGENT names, or its default.
39
+ const fromHome = contextFromHome();
40
+ if (fromHome)
41
+ return fromHome;
37
42
  return resolveDefaultContextPath();
38
43
  }
39
44
  /**
@@ -0,0 +1,101 @@
1
+ export declare const AGENT_NAME: RegExp;
2
+ export interface Household {
3
+ /** $LOOM_HOME: where household.json lives. */
4
+ home: string;
5
+ agents: string[];
6
+ /** The agent the owner's token acts as when it names none. */
7
+ default: string;
8
+ owner?: string;
9
+ }
10
+ export type Role = {
11
+ kind: 'agent';
12
+ agent: string;
13
+ } | {
14
+ kind: 'owner';
15
+ } | {
16
+ kind: 'peer';
17
+ name: string;
18
+ };
19
+ export interface TokenRecord {
20
+ label: string;
21
+ /** `agent:<name>`, `owner` or `peer:<name>`. */
22
+ role: string;
23
+ /** sha256 of the token, hex. The token itself is never stored. */
24
+ sha256: string;
25
+ created: string;
26
+ last_used?: string;
27
+ revoked?: string;
28
+ }
29
+ export declare class HouseholdError extends Error {
30
+ }
31
+ /** $LOOM_HOME, else ~/.config/loom. */
32
+ export declare function householdHome(env?: NodeJS.ProcessEnv): string;
33
+ export declare function agentDir(h: Household, agent: string): string;
34
+ /** The household at `home`, or null when there's none. Throws on a malformed file. */
35
+ export declare function loadHousehold(home: string): Household | null;
36
+ /**
37
+ * The household a context dir belongs to: one whose `agents/<name>` is this
38
+ * dir (after resolving symlinks, so an old `~/.config/loom/art` link still
39
+ * finds it). Null for a plain context dir.
40
+ */
41
+ export declare function householdOf(contextDir: string): {
42
+ household: Household;
43
+ agent: string;
44
+ } | null;
45
+ export declare function parseRole(s: string): Role | null;
46
+ export declare function roleString(r: Role): string;
47
+ export declare function loadTokens(home: string): TokenRecord[];
48
+ /** Mint a token. Returns it once; only its sha256 is kept. */
49
+ export declare function createToken(h: Household, input: {
50
+ role: Role;
51
+ label: string;
52
+ }, now?: Date): {
53
+ token: string;
54
+ record: TokenRecord;
55
+ };
56
+ export declare function revokeToken(h: Household, label: string, now?: Date): TokenRecord;
57
+ /**
58
+ * Resolves presented tokens to roles. Reads tokens.json when it changes (so
59
+ * a revoke takes effect on the next request), compares digests in constant
60
+ * time, and records when a token was last used, at most once a minute.
61
+ */
62
+ export declare class TokenResolver {
63
+ private readonly h;
64
+ private cache;
65
+ private lastTouch;
66
+ constructor(h: Household);
67
+ private current;
68
+ /**
69
+ * The role for a presented token, or null. Revoked tokens and roles naming a
70
+ * missing agent resolve to null. Throws HouseholdError when tokens.json is
71
+ * unreadable or untrusted: the caller fails closed.
72
+ */
73
+ resolve(presented: string): Role | null;
74
+ /** Whether a live owner token has this sha256 (the page's sessions check this, so revoking ends them). */
75
+ ownerDigestLive(digestHex: string): boolean;
76
+ private touch;
77
+ }
78
+ /**
79
+ * Turn a plain context dir into the first agent of a new household: move
80
+ * `<home>/<name>` to `<home>/agents/<name>`, leave a symlink at the old path
81
+ * so anything that still names it keeps working, and write household.json.
82
+ */
83
+ export declare function initHousehold(home: string, agent: string, opts?: {
84
+ owner?: string;
85
+ }): Household;
86
+ /**
87
+ * An agent's directory must be a real directory, not a symlink: `agents/mark
88
+ * -> art` would make two agents share one stack.
89
+ */
90
+ export declare function assertRealAgentDir(h: Household, agent: string): void;
91
+ /** Add an agent to a household (its directory must already exist under agents/). */
92
+ export declare function addAgent(h: Household, agent: string): Household;
93
+ /**
94
+ * The context dir a household names when nothing else does: with LOOM_HOME
95
+ * set and a household.json there, `agents/<LOOM_AGENT or the default>`.
96
+ * Undefined otherwise. Only an explicit LOOM_HOME is consulted here, so a
97
+ * stray household.json never changes where an unconfigured process looks.
98
+ * (A daemon is different: one whose context dir already is
99
+ * `<home>/agents/<name>` serves that household; see householdOf.)
100
+ */
101
+ export declare function contextFromHome(env?: NodeJS.ProcessEnv): string | undefined;