@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 +16 -1
- package/README.md +18 -0
- package/dist/cli/index.js +24 -0
- package/dist/cli/pieces.d.ts +24 -0
- package/dist/cli/pieces.js +139 -0
- package/dist/cli/subcommands.d.ts +1 -1
- package/dist/cli/subcommands.js +1 -1
- package/dist/index.js +6 -1
- package/package.json +1 -1
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.
|
|
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];
|
package/dist/cli/subcommands.js
CHANGED
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
|
-
|
|
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.
|
|
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",
|