@jbarket/loomai 0.6.0 → 0.7.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,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.7.0] - 2026-10-24
16
+
17
+ ### Added
18
+
19
+ - **Pieces: `loom <piece>` dispatches to `loom-<piece>` on PATH, and `loom
20
+ pieces` lists them.** loom's command namespace now reaches separate,
21
+ swappable extensions (tasks, secrets, a wake clock, …) the way `git` reaches
22
+ `git-<cmd>`. loom never links them, and they never write loom's stores. Core
23
+ commands always win, piece names are validated before PATH is searched,
24
+ loom's env passes through unchanged, and the piece's exit code comes back.
25
+ The tiers, the rules (alone, composes without requiring, a contract per
26
+ role, no secrets in argv) and how pieces meet through named seams are in
27
+ `docs/extensions.md`.
28
+
15
29
  ## [0.6.0] - 2026-10-24
16
30
 
17
31
  ### Added
@@ -574,7 +588,8 @@ Initial public release.
574
588
  stack and all added external-service dependencies or operational
575
589
  overhead.
576
590
 
577
- [Unreleased]: https://github.com/sleepunit-agents/loom/compare/v0.6.0...HEAD
591
+ [Unreleased]: https://github.com/sleepunit-agents/loom/compare/v0.7.0...HEAD
592
+ [0.7.0]: https://github.com/sleepunit-agents/loom/compare/v0.6.0...v0.7.0
578
593
  [0.6.0]: https://github.com/sleepunit-agents/loom/compare/v0.5.0...v0.6.0
579
594
  [0.5.0]: https://github.com/sleepunit-agents/loom/compare/v0.4.1...v0.5.0
580
595
  [0.4.1]: https://github.com/sleepunit-agents/loom/compare/v0.3.1...v0.4.1
package/README.md CHANGED
@@ -593,6 +593,24 @@ loom migrate --dry-run # check without applying
593
593
  loom migrate --json # machine-readable output
594
594
  ```
595
595
 
596
+ ### Pieces — `loom tasks`, `loom secrets`, … as separate programs
597
+
598
+ loom stays identity and memory. Everything else an agent leans on (tasks,
599
+ secrets, a wake clock, an approval page) is a **piece**: its own program,
600
+ reached through loom's command namespace the way `git` reaches `git-<cmd>`.
601
+ `loom tasks list` runs the first `loom-tasks` on `PATH` with `list`, and
602
+ passes back its exit code. Core commands can't be shadowed. Pieces never
603
+ write loom's stores, work alone, and compose when others are installed. Each
604
+ piece role has a contract, so an implementation can be swapped for another.
605
+
606
+ ```bash
607
+ loom pieces # what's installed (name → path)
608
+ loom tasks ready # → loom-tasks ready
609
+ ```
610
+
611
+ The rules, the dispatch details and how pieces compose:
612
+ [`docs/extensions.md`](docs/extensions.md).
613
+
596
614
  ### Harness manifests and self-describe
597
615
 
598
616
  A **harness** is the MCP-capable runtime the agent runs in (Claude Code,
package/dist/cli/index.js CHANGED
@@ -9,6 +9,7 @@ import { realStreams } from './io.js';
9
9
  import { SUBCOMMANDS } from './subcommands.js';
10
10
  import { extractGlobalFlags, firstNonGlobalIndex } from './args.js';
11
11
  import { REMOTE_VERBS, runRemote } from './remote.js';
12
+ import { findPiece, isPieceName, runPiece } from './pieces.js';
12
13
  const TOP_HELP = `Usage: loom <command> [options]
13
14
 
14
15
  Commands:
@@ -30,6 +31,10 @@ Commands:
30
31
  install Write loom-setup skill into a harness's skills dir
31
32
  doctor Probe the loom environment (read-only)
32
33
  migrate Apply pending schema migrations to memories.db
34
+ pieces List installed pieces (loom-<name> on PATH)
35
+
36
+ Pieces: \`loom <name> ...\` runs an installed extension, loom-<name> on PATH
37
+ (e.g. loom tasks list). Core commands always win. See docs/extensions.md.
33
38
 
34
39
  Global flags:
35
40
  --context-dir <path> Agent context dir (default: $LOOM_CONTEXT_DIR or ~/.config/loom/default)
@@ -50,6 +55,19 @@ async function readVersion() {
50
55
  return pkg.version;
51
56
  }
52
57
  export async function runCli(argv, io = realStreams()) {
58
+ // A piece (docs/extensions.md): not a core command, and loom-<name> is on PATH.
59
+ const pieceIdx = firstNonGlobalIndex(argv);
60
+ const pieceName = argv[pieceIdx];
61
+ if (isPieceName(pieceName)) {
62
+ const path = findPiece(pieceName, io.env);
63
+ if (path) {
64
+ if (pieceIdx > 0) {
65
+ io.stderr(`loom: global flags (${argv.slice(0, pieceIdx).join(' ')}) don't apply to pieces; put the piece's own flags after its name.\n`);
66
+ return 2;
67
+ }
68
+ return runPiece(path, argv.slice(pieceIdx + 1), io);
69
+ }
70
+ }
53
71
  const resolved = await resolveSubcommand(argv, io);
54
72
  if (typeof resolved === 'number')
55
73
  return resolved;
@@ -101,6 +119,8 @@ async function resolveSubcommand(argv, io) {
101
119
  }
102
120
  if (!SUBCOMMANDS.includes(first)) {
103
121
  io.stderr(`Unknown subcommand: ${first}\n`);
122
+ if (isPieceName(first))
123
+ io.stderr(`(no piece installed either: nothing named loom-${first} on PATH; \`loom pieces\` lists what is)\n`);
104
124
  io.stderr(TOP_HELP);
105
125
  return 2;
106
126
  }
@@ -174,6 +194,10 @@ async function dispatch(sub, rest, io) {
174
194
  const { run } = await import('./migrate.js');
175
195
  return run(rest, io);
176
196
  }
197
+ case 'pieces': {
198
+ const { runPiecesCommand } = await import('./pieces.js');
199
+ return runPiecesCommand(rest, io);
200
+ }
177
201
  default:
178
202
  io.stderr(`Subcommand not implemented yet: ${sub}\n`);
179
203
  return 2;
@@ -0,0 +1,24 @@
1
+ import type { IOStreams } from './io.js';
2
+ /** A piece name: lowercase, starts with a letter, no dots or slashes. */
3
+ export declare const PIECE_NAME: RegExp;
4
+ /** Whether `name` could be a piece (valid name, not a core subcommand). */
5
+ export declare function isPieceName(name: string | undefined): name is string;
6
+ /** The executable that answers `loom <name>`: the first `loom-<name>` on PATH. */
7
+ export declare function findPiece(name: string, env: NodeJS.ProcessEnv): string | undefined;
8
+ export interface InstalledPiece {
9
+ name: string;
10
+ path: string;
11
+ }
12
+ /** Every installed piece, first on PATH wins (the one `loom <name>` would run). */
13
+ export declare function listPieces(env: NodeJS.ProcessEnv): InstalledPiece[];
14
+ /**
15
+ * Run a piece with `args`, passing loom's env through unchanged. With the
16
+ * process's own streams the piece inherits the terminal (so it can prompt,
17
+ * e.g. for a secret's value); with captured streams (tests) its output is
18
+ * forwarded. Exit code: the piece's own, 128+signal if it was killed, 127 if
19
+ * it couldn't start.
20
+ */
21
+ export declare function runPiece(path: string, args: string[], io: IOStreams): Promise<number>;
22
+ /** `loom pieces`: list what's installed. */
23
+ export declare function runPiecesCommand(argv: string[], io: IOStreams): number;
24
+ export declare const PIECES_USAGE = "Usage: loom pieces [--json]\n\nList the installed pieces: every executable named loom-<name> on PATH, which\nanswers `loom <name> ...`. The first one on PATH wins; core commands can't be\nshadowed. See docs/extensions.md.\n";
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Pieces — `loom <piece> ...` runs the installed extension `loom-<piece>`
3
+ * (docs/extensions.md, t-1466).
4
+ *
5
+ * Git-style dispatch: an executable named `loom-tasks` on PATH answers
6
+ * `loom tasks list`. loom doesn't know what a piece does and never links its
7
+ * code: the piece is its own program, reached as its own process, so it can't
8
+ * reach into loom's stores and can be swapped for anything else that installs
9
+ * the same name and honors that piece's contract.
10
+ *
11
+ * Core subcommands always win (a `loom-recall` on PATH is never consulted),
12
+ * and a name must match PIECE_NAME before PATH is searched at all.
13
+ */
14
+ import { accessSync, constants, readdirSync, statSync } from 'node:fs';
15
+ import { delimiter, join } from 'node:path';
16
+ import { spawn } from 'node:child_process';
17
+ import { constants as osConstants } from 'node:os';
18
+ import { SUBCOMMANDS } from './subcommands.js';
19
+ /** A piece name: lowercase, starts with a letter, no dots or slashes. */
20
+ export const PIECE_NAME = /^[a-z][a-z0-9-]{0,39}$/;
21
+ const PREFIX = 'loom-';
22
+ const CORE = new Set(SUBCOMMANDS);
23
+ function isExecutableFile(path) {
24
+ try {
25
+ if (!statSync(path).isFile())
26
+ return false;
27
+ accessSync(path, constants.X_OK);
28
+ return true;
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
34
+ function pathDirs(env) {
35
+ return (env.PATH ?? '').split(delimiter).filter(Boolean);
36
+ }
37
+ /** Whether `name` could be a piece (valid name, not a core subcommand). */
38
+ export function isPieceName(name) {
39
+ return name !== undefined && PIECE_NAME.test(name) && !CORE.has(name);
40
+ }
41
+ /** The executable that answers `loom <name>`: the first `loom-<name>` on PATH. */
42
+ export function findPiece(name, env) {
43
+ if (!isPieceName(name))
44
+ return undefined;
45
+ for (const dir of pathDirs(env)) {
46
+ const candidate = join(dir, PREFIX + name);
47
+ if (isExecutableFile(candidate))
48
+ return candidate;
49
+ }
50
+ return undefined;
51
+ }
52
+ /** Every installed piece, first on PATH wins (the one `loom <name>` would run). */
53
+ export function listPieces(env) {
54
+ const seen = new Map();
55
+ for (const dir of pathDirs(env)) {
56
+ let entries;
57
+ try {
58
+ entries = readdirSync(dir);
59
+ }
60
+ catch {
61
+ continue;
62
+ }
63
+ for (const entry of entries.sort()) {
64
+ if (!entry.startsWith(PREFIX))
65
+ continue;
66
+ const name = entry.slice(PREFIX.length);
67
+ if (seen.has(name) || !isPieceName(name))
68
+ continue;
69
+ const path = join(dir, entry);
70
+ if (isExecutableFile(path))
71
+ seen.set(name, path);
72
+ }
73
+ }
74
+ return [...seen].map(([name, path]) => ({ name, path })).sort((a, b) => a.name.localeCompare(b.name));
75
+ }
76
+ /**
77
+ * Run a piece with `args`, passing loom's env through unchanged. With the
78
+ * process's own streams the piece inherits the terminal (so it can prompt,
79
+ * e.g. for a secret's value); with captured streams (tests) its output is
80
+ * forwarded. Exit code: the piece's own, 128+signal if it was killed, 127 if
81
+ * it couldn't start.
82
+ */
83
+ export async function runPiece(path, args, io) {
84
+ const inherit = io.stdin === process.stdin;
85
+ const child = spawn(path, args, {
86
+ env: io.env,
87
+ stdio: inherit ? 'inherit' : ['pipe', 'pipe', 'pipe'],
88
+ });
89
+ if (!inherit) {
90
+ child.stdout?.on('data', (d) => io.stdout(d.toString()));
91
+ child.stderr?.on('data', (d) => io.stderr(d.toString()));
92
+ if (child.stdin) {
93
+ if (io.stdinIsTTY)
94
+ child.stdin.end();
95
+ else
96
+ io.stdin.pipe(child.stdin);
97
+ }
98
+ }
99
+ return new Promise((resolve) => {
100
+ child.on('error', (err) => {
101
+ io.stderr(`loom: couldn't run ${path}: ${err.message}\n`);
102
+ resolve(127);
103
+ });
104
+ child.on('close', (code, signal) => {
105
+ if (signal)
106
+ resolve(128 + (osConstants.signals[signal] ?? 0));
107
+ else
108
+ resolve(code ?? 1);
109
+ });
110
+ });
111
+ }
112
+ /** `loom pieces`: list what's installed. */
113
+ export function runPiecesCommand(argv, io) {
114
+ const json = argv.includes('--json');
115
+ const rest = argv.filter((a) => a !== '--json');
116
+ if (rest.includes('--help') || rest.includes('-h')) {
117
+ io.stdout(PIECES_USAGE);
118
+ return 0;
119
+ }
120
+ if (rest.length) {
121
+ io.stderr(`loom pieces takes no arguments.\n${PIECES_USAGE}`);
122
+ return 2;
123
+ }
124
+ const pieces = listPieces(io.env);
125
+ if (json) {
126
+ io.stdout(JSON.stringify(pieces, null, 2) + '\n');
127
+ return 0;
128
+ }
129
+ io.stdout(pieces.length === 0
130
+ ? 'no pieces installed (a piece is an executable named loom-<name> on PATH)\n'
131
+ : pieces.map((p) => `${p.name}\t${p.path}`).join('\n') + '\n');
132
+ return 0;
133
+ }
134
+ export const PIECES_USAGE = `Usage: loom pieces [--json]
135
+
136
+ List the installed pieces: every executable named loom-<name> on PATH, which
137
+ answers \`loom <name> ...\`. The first one on PATH wins; core commands can't be
138
+ shadowed. See docs/extensions.md.
139
+ `;
@@ -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'];
6
+ export declare const SUBCOMMANDS: readonly ['wake', 'recall', 'remember', 'forget', 'update', 'memory', 'knowledge', 'person', 'update-identity', 'bootstrap', 'serve', 'inject', 'harness', 'install', 'doctor', 'migrate', 'pieces'];
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',
10
+ 'install', 'doctor', 'migrate', 'pieces',
11
11
  ];
package/dist/index.js CHANGED
@@ -27,6 +27,7 @@ import { createLoomServer } from './server.js';
27
27
  import { resolveContextDir } from './config.js';
28
28
  import { SUBCOMMANDS } from './cli/subcommands.js';
29
29
  import { firstNonGlobalIndex } from './cli/args.js';
30
+ import { findPiece } from './cli/pieces.js';
30
31
  const CLI_KEYWORDS = new Set(SUBCOMMANDS);
31
32
  function isCliInvocation(argv) {
32
33
  const args = argv.slice(2);
@@ -37,7 +38,11 @@ function isCliInvocation(argv) {
37
38
  return true;
38
39
  if (first === '--version' || first === '-V')
39
40
  return true;
40
- return CLI_KEYWORDS.has(first);
41
+ if (CLI_KEYWORDS.has(first))
42
+ return true;
43
+ // An installed piece (loom-<first> on PATH) is a CLI call too. Anything else
44
+ // still falls through to the stdio server, exactly as before.
45
+ return findPiece(first, process.env) !== undefined;
41
46
  }
42
47
  export { isCliInvocation };
43
48
  async function main() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jbarket/loomai",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "MCP server for persistent AI agent identity and memory. Single-file sqlite-vec + local ONNX embeddings; zero external services.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",