Skip to content

Commit 1a2dbef

Browse files
committed
chore(myco): refresh Myco integration and runtime hooks
Upgrade Myco integration to the latest runtime capabilities. This involves: * Refactoring Myco hook guards for robust cross-platform execution and layered binary resolution. * Introducing project identity and binding through "Grove." * Updating the core Myco configuration schema from v6 to v9, including new Cortex and release provenance settings. * Migrating agent integrations (Codex, Opencode) to connect directly to the Myco daemon via URL, leveraging enriched request context (project, session, branch), and adding pre-tool-use hooks. * Aligning internal file management with Myco's updated structure, reflected in gitignore changes.
1 parent ad9a209 commit 1a2dbef

11 files changed

Lines changed: 416 additions & 69 deletions

File tree

.agents/myco-cli.cjs

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
#!/usr/bin/env node
2+
// Myco hook guard — silently no-ops when myco is not installed.
3+
//
4+
// This file is committed to the repo so open-source contributors without
5+
// Myco don't see hook errors in their agent sessions. It stays deliberately
6+
// thin: its only jobs are (1) provide a cross-platform entry point that
7+
// works under every shell our symbionts fire hooks from, and (2) resolve
8+
// which myco binary to exec via the layered runtime.command pin
9+
// (project-scope `<project>/.myco/runtime.command` first, then machine-scope
10+
// `~/.myco/runtime.command`).
11+
//
12+
// Managed by: myco init / myco update
13+
// Safe to delete: myco remove
14+
'use strict';
15+
16+
// Skip hooks for Myco's own agent pipeline sessions — they are internal
17+
// and should not be captured as user sessions.
18+
if (process.env.MYCO_AGENT_SESSION) process.exit(0);
19+
20+
const fs = require('node:fs');
21+
const os = require('node:os');
22+
const path = require('node:path');
23+
const { execFileSync } = require('node:child_process');
24+
25+
// Defensively pin cwd to the project root. Cursor's hook spawn drops stdin
26+
// when the command uses shell operators, so our installed hook commands
27+
// invoke this guard directly (no `cd "$(...)" &&` prefix). The chdir keeps
28+
// vault resolution working even when the spawning agent's cwd isn't set.
29+
try { process.chdir(path.resolve(__dirname, '..')); } catch { /* best effort */ }
30+
31+
// Resolve which myco binary to invoke.
32+
//
33+
// `~/.myco/runtime.command` is the source of truth — a one-line plain-text
34+
// file holding either a PATH-resolvable name (the default for globally-
35+
// installed users is the file's absence) or an absolute path to a managed/
36+
// dev binary (what `make dev-link` writes; what the beta-channel installer
37+
// writes). Absolute paths bypass PATH entirely, which matters because GUI-
38+
// launched agents (Cursor, Claude Code desktop, etc.) run under macOS
39+
// launchd and inherit a minimal PATH that typically doesn't include
40+
// `~/.local/bin`.
41+
//
42+
// Machine-scoped: there's exactly one daemon per machine, and the runtime
43+
// that backs it is a machine-level choice, not per-project.
44+
const args = process.argv.slice(2);
45+
const bin = readLayeredRuntimeCommand() ?? 'myco';
46+
47+
function readPinFile(filePath) {
48+
try {
49+
const raw = fs.readFileSync(filePath, 'utf-8').trim();
50+
return raw || null;
51+
} catch { return null; }
52+
}
53+
54+
function readProjectRuntimeCommand(startDir) {
55+
let dir = path.resolve(startDir);
56+
while (true) {
57+
const pin = readPinFile(path.join(dir, '.myco', 'runtime.command'));
58+
if (pin) return pin;
59+
const parent = path.dirname(dir);
60+
if (parent === dir) return null;
61+
dir = parent;
62+
}
63+
}
64+
65+
function readMachineRuntimeCommand() {
66+
const home = process.env.MYCO_HOME ? expandHome(process.env.MYCO_HOME) : path.join(os.homedir(), '.myco');
67+
return readPinFile(path.join(home, 'runtime.command'));
68+
}
69+
70+
function readLayeredRuntimeCommand() {
71+
return readProjectRuntimeCommand(process.cwd()) ?? readMachineRuntimeCommand();
72+
}
73+
74+
function expandHome(value) {
75+
if (value === '~') return os.homedir();
76+
if (value.startsWith(`~${path.sep}`)) return path.join(os.homedir(), value.slice(2));
77+
return value;
78+
}
79+
80+
function toolNameFromArgs(args) {
81+
if (args[0] !== 'tool' || args[1] !== 'call') return undefined;
82+
for (let idx = 2; idx < args.length; idx++) {
83+
const arg = args[idx];
84+
if (arg === '--json') continue;
85+
if (arg === '--input') {
86+
idx++;
87+
continue;
88+
}
89+
if (arg && !arg.startsWith('-')) return arg;
90+
}
91+
return undefined;
92+
}
93+
94+
function writeToolRuntimeUnavailable(command, args) {
95+
const tool = toolNameFromArgs(args);
96+
const envelope = {
97+
ok: false,
98+
...(tool ? { tool } : {}),
99+
error: {
100+
code: 'runtime_unavailable',
101+
message: `Myco runtime command '${command}' could not be found. Check <project>/.myco/runtime.command and ~/.myco/runtime.command, or run Myco update from a shell where Myco is installed.`,
102+
},
103+
};
104+
process.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`);
105+
}
106+
107+
try {
108+
execFileSync(bin, args, { stdio: 'inherit' });
109+
} catch (e) {
110+
if (e.code === 'ENOENT') {
111+
if (args[0] === 'tool') {
112+
writeToolRuntimeUnavailable(bin, args);
113+
process.exit(1);
114+
}
115+
process.exit(0);
116+
}
117+
process.exit(e.status ?? 1);
118+
}

.agents/myco-run.cjs

Lines changed: 84 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
// Myco don't see hook errors in their agent sessions. It stays deliberately
66
// thin: its only jobs are (1) provide a cross-platform entry point that
77
// works under every shell our symbionts fire hooks from, and (2) resolve
8-
// which myco binary to exec via .myco/runtime.command.
8+
// which myco binary to exec via the layered runtime.command pin
9+
// (project-scope `<project>/.myco/runtime.command` first, then machine-scope
10+
// `~/.myco/runtime.command`).
911
//
1012
// Managed by: myco init / myco update
1113
// Safe to delete: myco remove
@@ -16,6 +18,7 @@
1618
if (process.env.MYCO_AGENT_SESSION) process.exit(0);
1719

1820
const fs = require('node:fs');
21+
const os = require('node:os');
1922
const path = require('node:path');
2023
const { execFileSync } = require('node:child_process');
2124

@@ -27,27 +30,89 @@ try { process.chdir(path.resolve(__dirname, '..')); } catch { /* best effort */
2730

2831
// Resolve which myco binary to invoke.
2932
//
30-
// `.myco/runtime.command` is the source of truth — a one-line plain-text
31-
// file holding either a PATH-resolvable name (`myco`, the default for
32-
// globally-installed users) or an absolute path (`/Users/chris/.local/
33-
// bin/myco-dev`, what `make dev-link` writes). Absolute paths bypass
34-
// PATH entirely, which matters because GUI-launched agents (Cursor,
35-
// Claude Code desktop, etc.) run under macOS launchd and inherit a
36-
// minimal PATH that typically doesn't include `~/.local/bin`.
33+
// `~/.myco/runtime.command` is the source of truth — a one-line plain-text
34+
// file holding either a PATH-resolvable name (the default for globally-
35+
// installed users is the file's absence) or an absolute path to a managed/
36+
// dev binary (what `make dev-link` writes; what the beta-channel installer
37+
// writes). Absolute paths bypass PATH entirely, which matters because GUI-
38+
// launched agents (Cursor, Claude Code desktop, etc.) run under macOS
39+
// launchd and inherit a minimal PATH that typically doesn't include
40+
// `~/.local/bin`.
3741
//
38-
// We locate the alias file via __dirname so the guard doesn't depend on
39-
// cwd. Hook wrappers `cd` to the project root before invoking us, but
40-
// not every agent keeps that contract across every shell.
41-
let bin = 'myco';
42-
try {
43-
const aliasPath = path.resolve(__dirname, '..', '.myco', 'runtime.command');
44-
const alias = fs.readFileSync(aliasPath, 'utf-8').trim();
45-
if (alias) bin = alias;
46-
} catch { /* missing file → use default */ }
42+
// Machine-scoped: there's exactly one daemon per machine, and the runtime
43+
// that backs it is a machine-level choice, not per-project.
44+
const args = process.argv.slice(2);
45+
const bin = readLayeredRuntimeCommand() ?? 'myco';
46+
47+
function readPinFile(filePath) {
48+
try {
49+
const raw = fs.readFileSync(filePath, 'utf-8').trim();
50+
return raw || null;
51+
} catch { return null; }
52+
}
53+
54+
function readProjectRuntimeCommand(startDir) {
55+
let dir = path.resolve(startDir);
56+
while (true) {
57+
const pin = readPinFile(path.join(dir, '.myco', 'runtime.command'));
58+
if (pin) return pin;
59+
const parent = path.dirname(dir);
60+
if (parent === dir) return null;
61+
dir = parent;
62+
}
63+
}
64+
65+
function readMachineRuntimeCommand() {
66+
const home = process.env.MYCO_HOME ? expandHome(process.env.MYCO_HOME) : path.join(os.homedir(), '.myco');
67+
return readPinFile(path.join(home, 'runtime.command'));
68+
}
69+
70+
function readLayeredRuntimeCommand() {
71+
return readProjectRuntimeCommand(process.cwd()) ?? readMachineRuntimeCommand();
72+
}
73+
74+
function expandHome(value) {
75+
if (value === '~') return os.homedir();
76+
if (value.startsWith(`~${path.sep}`)) return path.join(os.homedir(), value.slice(2));
77+
return value;
78+
}
79+
80+
function toolNameFromArgs(args) {
81+
if (args[0] !== 'tool' || args[1] !== 'call') return undefined;
82+
for (let idx = 2; idx < args.length; idx++) {
83+
const arg = args[idx];
84+
if (arg === '--json') continue;
85+
if (arg === '--input') {
86+
idx++;
87+
continue;
88+
}
89+
if (arg && !arg.startsWith('-')) return arg;
90+
}
91+
return undefined;
92+
}
93+
94+
function writeToolRuntimeUnavailable(command, args) {
95+
const tool = toolNameFromArgs(args);
96+
const envelope = {
97+
ok: false,
98+
...(tool ? { tool } : {}),
99+
error: {
100+
code: 'runtime_unavailable',
101+
message: `Myco runtime command '${command}' could not be found. Check <project>/.myco/runtime.command and ~/.myco/runtime.command, or run Myco update from a shell where Myco is installed.`,
102+
},
103+
};
104+
process.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`);
105+
}
47106

48107
try {
49-
execFileSync(bin, process.argv.slice(2), { stdio: 'inherit' });
108+
execFileSync(bin, args, { stdio: 'inherit' });
50109
} catch (e) {
51-
if (e.code === 'ENOENT') process.exit(0);
110+
if (e.code === 'ENOENT') {
111+
if (args[0] === 'tool') {
112+
writeToolRuntimeUnavailable(bin, args);
113+
process.exit(1);
114+
}
115+
process.exit(0);
116+
}
52117
process.exit(e.status ?? 1);
53118
}

.codex/config.toml

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
[mcp_servers.myco]
2-
command = "myco-run"
3-
args = ["mcp"]
4-
cwd = "."
2+
url = "http://127.0.0.1:20915/mcp"
53

64
[features]
7-
codex_hooks = true
5+
hooks = true

.codex/hooks.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,17 @@
1111
]
1212
}
1313
],
14+
"PreToolUse": [
15+
{
16+
"hooks": [
17+
{
18+
"type": "command",
19+
"command": "node .agents/myco-run.cjs hook pre-tool-use --symbiont codex",
20+
"timeout": 3
21+
}
22+
]
23+
}
24+
],
1425
"UserPromptSubmit": [
1526
{
1627
"hooks": [

.myco/.gitignore

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,21 @@ team/
2626
# Staged files generated by the myco agent
2727
staging/
2828

29-
# Runtime command alias — per-contributor override for which myco binary
30-
# the hook guard invokes. Default (file absent) is `myco`; `make dev-link`
31-
# writes `myco-dev`; users can hand-edit for PATH conflicts or pinning.
32-
# Never committed — different contributors use different aliases.
33-
runtime.command
29+
# Grove activation marker/state — local to this checkout and contains
30+
# machine-local paths and import metadata; never committed.
31+
migration/
3432

35-
# Project-local managed runtime used for beta isolation
36-
runtime/
37-
runtime.tmp/
33+
# Grove project binding — per-machine. `project.toml` itself is committed
34+
# (portable project + Grove identity); the binding lives here.
35+
project.local.toml
3836

3937
# Per-user appearance and settings overrides
4038
local.yaml
39+
40+
# Project-scope runtime pin written by `make dev-link`. Per-machine,
41+
# never committed.
42+
runtime.command
43+
44+
# Grove migration archive — timestamped snapshot of pre-Grove vault data
45+
# moved aside post-activation. Never committed; recoverable on disk.
46+
.archive-*/

0 commit comments

Comments
 (0)