mirror-mind 0.33.3 → 0.34.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/.agents/plugins/marketplace.json +16 -0
- package/AGENTS.md +4 -4
- package/docs/releases/index.md +2 -0
- package/docs/releases/v0.33.4.md +104 -0
- package/docs/releases/v0.34.0.md +142 -0
- package/package.json +2 -3
- package/plugins/mirror-mind/.claude-plugin/plugin.json +1 -1
- package/ts/src/conversation/backfill.ts +13 -129
- package/ts/src/conversation/commandTurn.ts +42 -0
- package/ts/src/conversation/deferredCloseTail.ts +154 -0
- package/ts/src/conversation/logger.ts +104 -20
- package/ts/src/conversation/loggerCli.ts +6 -18
- package/ts/src/conversation/loggerRuntime.ts +6 -1
- package/ts/src/conversation/runtimePolicy.ts +50 -0
- package/ts/src/conversation/sessionComposites.ts +54 -8
- package/ts/src/frontDoor/cli.ts +12 -2
- package/ts/src/frontDoor/routing.ts +3 -2
- package/ts/src/frontDoor/runtimeRoute.ts +13 -1
- package/ts/src/guards/claudePlugin.ts +39 -2
- package/ts/src/guards/packContents.ts +4 -4
- package/ts/src/guards/retiredSurfaces.ts +36 -0
- package/ts/src/hooks/claude.ts +16 -4
- package/ts/src/hooks/gemini.ts +3 -1
- package/ts/src/hooks/main.ts +6 -16
- package/ts/src/identity/identityEdit.ts +11 -2
- package/ts/src/identity/identityKey.ts +18 -2
- package/ts/src/providers/config.ts +11 -0
- package/ts/src/runtime/diagnose.ts +115 -1
- package/ts/src/runtime/pluginWiring.ts +135 -0
- package/ts/src/runtime/update.ts +39 -6
- package/ts/src/runtime/wiring.ts +24 -10
- package/scripts/codex-hooks/session-end.sh +0 -85
- package/scripts/codex-hooks/session-start.sh +0 -85
- package/scripts/codex-mirror.sh +0 -48
- package/ts/src/hooks/codex.ts +0 -45
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// The plugin wiring readers (CV21.DS4.TS6).
|
|
2
|
+
//
|
|
3
|
+
// Two runtimes hold the plugin outside the install tree. Codex copies it into
|
|
4
|
+
// a versioned cache (`$CODEX_HOME/plugins/cache/mirror-mind/mirror-mind/
|
|
5
|
+
// <version>`; CV21.DS4.US1), so an npm update changes the install but not
|
|
6
|
+
// that copy — the one wiring that goes stale. Claude Code links the plugin in
|
|
7
|
+
// place (`~/.claude/skills/mirror-mind`), so its failure mode is a dangling
|
|
8
|
+
// link, not a stale copy.
|
|
9
|
+
//
|
|
10
|
+
// Both readers take their roots as ARGUMENTS, always. The defaulting —
|
|
11
|
+
// `$CODEX_HOME` else `~/.codex`, and the Claude home — happens once, at the
|
|
12
|
+
// diagnose entry, from the env the caller already carries, never in here.
|
|
13
|
+
// That is the CR123/128/137/142 lesson: a maintainer's machine has a real
|
|
14
|
+
// ~/.codex and a real ~/.claude, and a reader that consults the environment
|
|
15
|
+
// makes every fixture test machine-dependent exactly where maintainers work.
|
|
16
|
+
//
|
|
17
|
+
// Nothing here runs `codex` or mutates anything: Mirror reads directories and
|
|
18
|
+
// prints a command for the person to run. On the installed Codex 0.163.0,
|
|
19
|
+
// `codex plugin` has no update verb (add, list, marketplace, remove), so the
|
|
20
|
+
// level-up command is the re-add — `CODEX_PLUGIN_ADD_COMMAND`, the same
|
|
21
|
+
// constant `mirror init`'s wiring lines print (the CR071 lesson at the
|
|
22
|
+
// string level).
|
|
23
|
+
|
|
24
|
+
import { lstatSync, readdirSync, readFileSync, readlinkSync, statSync } from "node:fs";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import { compareSemver, parseSemver } from "#util/semver.ts";
|
|
27
|
+
|
|
28
|
+
/** Where Codex keeps its copy, below a Codex home. */
|
|
29
|
+
export const CODEX_PLUGIN_CACHE_SEGMENTS: readonly string[] = [
|
|
30
|
+
"plugins",
|
|
31
|
+
"cache",
|
|
32
|
+
"mirror-mind",
|
|
33
|
+
"mirror-mind",
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
export interface CodexCacheState {
|
|
37
|
+
/** `absent` covers no cache and a cache with no version-named entry. */
|
|
38
|
+
state: "absent" | "current" | "behind" | "ahead";
|
|
39
|
+
cachePath: string;
|
|
40
|
+
/** The newest version-named entry, by semver — never a string sort. */
|
|
41
|
+
newestCached: string | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Read the Codex plugin cache below `codexHome`, read-only. Entries whose
|
|
46
|
+
* names do not parse as versions (a partial download, an editor's leavings)
|
|
47
|
+
* are ignored; a cache holding only those claims nothing and reads as absent.
|
|
48
|
+
*
|
|
49
|
+
* `ahead` is a state, not a finding: `runtime update` refuses downgrades but
|
|
50
|
+
* `npm install -g mirror-mind@old` does not ask, and the person who
|
|
51
|
+
* downgraded on purpose needs no Codex advice from the older tree.
|
|
52
|
+
*/
|
|
53
|
+
export function codexPluginCacheState(
|
|
54
|
+
codexHome: string,
|
|
55
|
+
installedVersion: string,
|
|
56
|
+
): CodexCacheState {
|
|
57
|
+
const cachePath = join(codexHome, ...CODEX_PLUGIN_CACHE_SEGMENTS);
|
|
58
|
+
let entries: string[];
|
|
59
|
+
try {
|
|
60
|
+
entries = readdirSync(cachePath);
|
|
61
|
+
} catch {
|
|
62
|
+
return { state: "absent", cachePath, newestCached: null };
|
|
63
|
+
}
|
|
64
|
+
let newest: string | null = null;
|
|
65
|
+
for (const entry of entries) {
|
|
66
|
+
if (parseSemver(entry)[0] < 0) continue;
|
|
67
|
+
try {
|
|
68
|
+
if (!statSync(join(cachePath, entry)).isDirectory()) continue;
|
|
69
|
+
} catch {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (newest === null || compareSemver(entry, newest) > 0) newest = entry;
|
|
73
|
+
}
|
|
74
|
+
if (newest === null) return { state: "absent", cachePath, newestCached: null };
|
|
75
|
+
const order = compareSemver(newest, installedVersion);
|
|
76
|
+
const state = order < 0 ? "behind" : order > 0 ? "ahead" : "current";
|
|
77
|
+
return { state, cachePath, newestCached: newest };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface ClaudeLinkState {
|
|
81
|
+
/** `healthy` means the target reads — including a plain directory copy. */
|
|
82
|
+
state: "absent" | "healthy" | "dangling";
|
|
83
|
+
linkPath: string;
|
|
84
|
+
/** The link's own target when it is a symlink, else null. */
|
|
85
|
+
target: string | null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Read the Claude Code wiring below `claudeHome`. Healthy means the target
|
|
90
|
+
* reads; a link that resolves to *another* install tree is deliberately
|
|
91
|
+
* healthy too (TS6's documented silence) — chasing where it points is a
|
|
92
|
+
* one-line amendment here if that case ever bites.
|
|
93
|
+
*/
|
|
94
|
+
export function claudePluginLinkState(claudeHome: string): ClaudeLinkState {
|
|
95
|
+
const linkPath = join(claudeHome, "skills", "mirror-mind");
|
|
96
|
+
let isLink = false;
|
|
97
|
+
try {
|
|
98
|
+
isLink = lstatSync(linkPath).isSymbolicLink();
|
|
99
|
+
} catch {
|
|
100
|
+
return { state: "absent", linkPath, target: null };
|
|
101
|
+
}
|
|
102
|
+
const target = isLink ? readlinkSync(linkPath) : null;
|
|
103
|
+
try {
|
|
104
|
+
statSync(linkPath);
|
|
105
|
+
} catch {
|
|
106
|
+
return { state: "dangling", linkPath, target };
|
|
107
|
+
}
|
|
108
|
+
return { state: "healthy", linkPath, target };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The install tree's own plugin version, from the generated manifest.
|
|
113
|
+
* `plugins/mirror-mind/.claude-plugin/plugin.json` is written by
|
|
114
|
+
* `claudePlugin.ts` from `packageVersion()`, and the guard holds the
|
|
115
|
+
* committed manifest equal to the generated one — the file is the authority,
|
|
116
|
+
* not an assumption. Reading it FROM DISK is also what makes a post-update
|
|
117
|
+
* hint honest: the process that ran the update is the old version's code,
|
|
118
|
+
* but the tree it reads has already landed the new one.
|
|
119
|
+
*/
|
|
120
|
+
export function installedPluginVersion(installRoot: string): string | null {
|
|
121
|
+
try {
|
|
122
|
+
const raw = readFileSync(
|
|
123
|
+
join(installRoot, "plugins", "mirror-mind", ".claude-plugin", "plugin.json"),
|
|
124
|
+
"utf8",
|
|
125
|
+
);
|
|
126
|
+
const parsed: unknown = JSON.parse(raw);
|
|
127
|
+
const version =
|
|
128
|
+
typeof parsed === "object" && parsed !== null
|
|
129
|
+
? (parsed as Record<string, unknown>).version
|
|
130
|
+
: undefined;
|
|
131
|
+
return typeof version === "string" && version !== "" ? version : null;
|
|
132
|
+
} catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
package/ts/src/runtime/update.ts
CHANGED
|
@@ -26,8 +26,10 @@
|
|
|
26
26
|
// chosen once, below; no step asks which lane it is in.
|
|
27
27
|
|
|
28
28
|
import { spawnSync } from "node:child_process";
|
|
29
|
+
import { join } from "node:path";
|
|
29
30
|
import { verifyBackupArchive } from "#runtime/backup.ts";
|
|
30
31
|
import type { InstallKind } from "#runtime/installKind.ts";
|
|
32
|
+
import { codexPluginCacheState, installedPluginVersion } from "#runtime/pluginWiring.ts";
|
|
31
33
|
import { cloneStrategy, defaultGitRunner, type GitRunner } from "#runtime/strategies/clone.ts";
|
|
32
34
|
import { defaultNpmRunner, type NpmRunner, packageStrategy } from "#runtime/strategies/package.ts";
|
|
33
35
|
import type { statusAllowsUpdatePreflight } from "#runtime/updateGate.ts";
|
|
@@ -37,6 +39,7 @@ import {
|
|
|
37
39
|
type UpdateResult,
|
|
38
40
|
type UpdateStage,
|
|
39
41
|
} from "#runtime/updatePipeline.ts";
|
|
42
|
+
import { CODEX_PLUGIN_ADD_COMMAND } from "#runtime/wiring.ts";
|
|
40
43
|
import { commandOnPath } from "#util/paths.ts";
|
|
41
44
|
import { PROGRAM } from "#util/program.ts";
|
|
42
45
|
import { shellWord } from "#util/shellWord.ts";
|
|
@@ -405,12 +408,42 @@ export function postUpdateHints(
|
|
|
405
408
|
env: NodeJS.ProcessEnv,
|
|
406
409
|
canExecute?: (path: string) => boolean,
|
|
407
410
|
): string[] {
|
|
408
|
-
if (
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
411
|
+
if (!result.success) return [];
|
|
412
|
+
const hints: string[] = [];
|
|
413
|
+
|
|
414
|
+
if (install.kind === "clone" && commandOnPath(PROGRAM, env, canExecute) === null) {
|
|
415
|
+
// A command the person will paste: the path is one shell word (CR104).
|
|
416
|
+
hints.push(
|
|
417
|
+
`\`${PROGRAM}\` is not on the PATH: run \`npm link\` once in ${shellWord(install.repository)}, so the skills can call it.`,
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// CV21.DS4.TS6: Codex runs the plugin copy it cached at install, so the
|
|
422
|
+
// update that just landed did not reach it. The version authority is the
|
|
423
|
+
// LANDED version, read from the just-updated tree's plugin.json -- this
|
|
424
|
+
// process is the old version's code, and its own constant would read a
|
|
425
|
+
// just-staled cache as current.
|
|
426
|
+
const installRoot =
|
|
427
|
+
install.kind === "clone"
|
|
428
|
+
? install.repository
|
|
429
|
+
: install.kind === "package"
|
|
430
|
+
? install.root
|
|
431
|
+
: null;
|
|
432
|
+
const home = env.HOME?.trim() || null;
|
|
433
|
+
const codexHome = env.CODEX_HOME?.trim() || (home ? join(home, ".codex") : null);
|
|
434
|
+
if (installRoot && codexHome) {
|
|
435
|
+
const landed = installedPluginVersion(installRoot);
|
|
436
|
+
if (landed) {
|
|
437
|
+
const cache = codexPluginCacheState(codexHome, landed);
|
|
438
|
+
if (cache.state === "behind") {
|
|
439
|
+
hints.push(
|
|
440
|
+
`Codex's cached plugin is behind the update (${cache.newestCached} < ${landed}): run \`${CODEX_PLUGIN_ADD_COMMAND}\` to bring it level.`,
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return hints;
|
|
414
447
|
}
|
|
415
448
|
|
|
416
449
|
function firstLine(text: string): string {
|
package/ts/src/runtime/wiring.ts
CHANGED
|
@@ -23,17 +23,31 @@
|
|
|
23
23
|
// and hooks live in `~/.gemini/settings.json`, absolute paths.
|
|
24
24
|
// Two steps. The Operating Instructions arrive through the
|
|
25
25
|
// SessionStart hook (D13).
|
|
26
|
-
// Codex
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
26
|
+
// Codex `codex plugin marketplace add <root>` reads the package's
|
|
27
|
+
// `.agents/plugins/marketplace.json`, and `codex plugin add
|
|
28
|
+
// mirror-mind@mirror-mind` copies the plugin into Codex's cache
|
|
29
|
+
// with its skills, hooks, and MCP server (0.162.1, CV21.DS12.TS2
|
|
30
|
+
// static 5-7; CV21.DS4.US1). Two steps. Codex asks once, at the
|
|
31
|
+
// next session, to trust the plugin's hooks (L1); the Operating
|
|
32
|
+
// Instructions then arrive through its SessionStart hook as a
|
|
33
|
+
// developer message (L4). Until DS4.US1 the two steps were a
|
|
34
|
+
// skills link and an AGENTS.md link, with sessions run through
|
|
35
|
+
// the shipped wrapper.
|
|
31
36
|
|
|
32
37
|
import { join } from "node:path";
|
|
33
38
|
import { commandOnPath } from "#util/paths.ts";
|
|
34
39
|
|
|
35
40
|
export type RuntimeName = "pi" | "claude" | "gemini" | "codex";
|
|
36
41
|
|
|
42
|
+
/**
|
|
43
|
+
* The one Codex level-up command (CV21.DS4.TS6). Codex 0.163.0 has no
|
|
44
|
+
* plugin-update verb, so re-adding is how a cached plugin reaches the
|
|
45
|
+
* installed version. One constant so the wiring line `mirror init` prints
|
|
46
|
+
* and the recommendation `runtime diagnose` prints cannot disagree (the
|
|
47
|
+
* CR071 lesson at the string level).
|
|
48
|
+
*/
|
|
49
|
+
export const CODEX_PLUGIN_ADD_COMMAND = "codex plugin add mirror-mind@mirror-mind";
|
|
50
|
+
|
|
37
51
|
export interface RuntimeWiring {
|
|
38
52
|
/** The runtime, as a person names it. */
|
|
39
53
|
readonly title: string;
|
|
@@ -94,11 +108,11 @@ export const RUNTIME_WIRING: Readonly<Record<RuntimeName, RuntimeWiring>> = {
|
|
|
94
108
|
title: "Codex",
|
|
95
109
|
command: "codex",
|
|
96
110
|
steps: [
|
|
97
|
-
(root) => [`
|
|
98
|
-
(
|
|
99
|
-
|
|
100
|
-
"(
|
|
101
|
-
|
|
111
|
+
(root) => [`codex plugin marketplace add "${root}"`],
|
|
112
|
+
() => [
|
|
113
|
+
CODEX_PLUGIN_ADD_COMMAND,
|
|
114
|
+
"(Codex asks once, at the next session, to trust the plugin's hooks; the plugin",
|
|
115
|
+
"then loads every session: its skills, the hooks, and the MCP server)",
|
|
102
116
|
],
|
|
103
117
|
],
|
|
104
118
|
},
|
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# Mirror Mind hook wrapper — GENERATED by scripts/generate_hook_wrappers.sh.
|
|
3
|
-
# Edit the generator, never this file: every wrapper shares these bytes and
|
|
4
|
-
# differs only in the hook name and in how it finds the Mirror entry
|
|
5
|
-
# (CV22.DS10.TS5 decision D4; CV22.DS10.US3 decision D5).
|
|
6
|
-
set -uo pipefail
|
|
7
|
-
HOOK="codex:session-end"
|
|
8
|
-
|
|
9
|
-
# Resolve the tree from THIS FILE, never from the cwd: a runtime spawns hooks
|
|
10
|
-
# from whatever directory the session is in. The tree is a checkout or the
|
|
11
|
-
# installed package; the wrapper's depth to its root is the same in both.
|
|
12
|
-
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
|
|
13
|
-
REPO_ROOT="$(cd "$HERE/../.." && pwd -P)"
|
|
14
|
-
|
|
15
|
-
# A hook never fails the user's turn, and never fails in silence: when it
|
|
16
|
-
# cannot do its job it appends ONE line to <mirror home>/hooks.log and exits 0.
|
|
17
|
-
# The home is found the way the core finds it -- MIRROR_HOME, else
|
|
18
|
-
# MIRROR_USER -- from the environment when set there, else from the tree's
|
|
19
|
-
# .env when the wrapper is in a tree, else from the user's config file
|
|
20
|
-
# (~/.config/mirror/env), in the core's order (US3, D3). A GUI launch carries
|
|
21
|
-
# neither variable, and the first wrappers then wrote to a home nobody reads.
|
|
22
|
-
env_file_value() {
|
|
23
|
-
[ -f "$2" ] || return 0
|
|
24
|
-
sed -n -E "s/^[[:space:]]*(export[[:space:]]+)?$1[[:space:]]*=[[:space:]]*//p" "$2" \
|
|
25
|
-
| tail -n 1 | tr -d "\"'\r"
|
|
26
|
-
}
|
|
27
|
-
env_value() {
|
|
28
|
-
local value=""
|
|
29
|
-
if [ -n "${!1+set}" ]; then printf '%s' "${!1}"; return; fi
|
|
30
|
-
[ -z "$REPO_ROOT" ] || value="$(env_file_value "$1" "$REPO_ROOT/.env")"
|
|
31
|
-
[ -n "$value" ] || value="$(env_file_value "$1" "${XDG_CONFIG_HOME:-$HOME/.config}/mirror/env")"
|
|
32
|
-
printf '%s' "$value"
|
|
33
|
-
}
|
|
34
|
-
note() {
|
|
35
|
-
local home user
|
|
36
|
-
home="$(env_value MIRROR_HOME)"
|
|
37
|
-
user="$(env_value MIRROR_USER)"
|
|
38
|
-
if [ -n "$home" ]; then
|
|
39
|
-
home="${home/#\~/$HOME}"
|
|
40
|
-
elif [ -n "$user" ]; then
|
|
41
|
-
home="$HOME/.mirror-minds/$user"
|
|
42
|
-
[ -d "$home" ] || [ ! -d "$HOME/.mirror/$user" ] || home="$HOME/.mirror/$user"
|
|
43
|
-
else
|
|
44
|
-
return 0
|
|
45
|
-
fi
|
|
46
|
-
mkdir -p "$home" 2>/dev/null \
|
|
47
|
-
&& printf '%s %s: %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$HOOK" "$1" >> "$home/hooks.log" 2>/dev/null
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
ENTRY="$REPO_ROOT/bin/mirror-hook.js"
|
|
51
|
-
|
|
52
|
-
# Find Node. /usr/bin/python3 exists on every macOS; node usually lives under
|
|
53
|
-
# nvm or Homebrew and is NOT on the PATH of a GUI-launched runtime. The Python
|
|
54
|
-
# hooks could end in `|| true` because they always found their interpreter;
|
|
55
|
-
# the same silence here would hide a new failure — no logging, no inject, no
|
|
56
|
-
# close tail — that a user would meet weeks later as "Mirror stopped
|
|
57
|
-
# remembering". So an unresolvable Node is RECORDED, never swallowed. The
|
|
58
|
-
# directory the entry was found in is searched first: an npm global bin holds
|
|
59
|
-
# the `node` that installed the package beside `mirror-hook`, while a system
|
|
60
|
-
# `/usr/local/bin/node` can be years older (CI's Ubuntu runner has one).
|
|
61
|
-
NODE="${MIRROR_NODE:-}"
|
|
62
|
-
if [ -z "$NODE" ] || [ ! -x "$NODE" ]; then
|
|
63
|
-
NODE="$(command -v node 2>/dev/null || true)"
|
|
64
|
-
fi
|
|
65
|
-
for candidate in "$(dirname "$ENTRY")/node" "$HOME/.nvm/current/bin/node" /opt/homebrew/bin/node /usr/local/bin/node; do
|
|
66
|
-
[ -n "$NODE" ] && break
|
|
67
|
-
[ -x "$candidate" ] && NODE="$candidate"
|
|
68
|
-
done
|
|
69
|
-
|
|
70
|
-
if [ -z "$NODE" ]; then
|
|
71
|
-
note "node not found on PATH; hook skipped. Set MIRROR_NODE."
|
|
72
|
-
exit 0
|
|
73
|
-
fi
|
|
74
|
-
|
|
75
|
-
# Not `exec`: the entry exits 0 whatever happens inside it, so a non-zero exit
|
|
76
|
-
# means Node itself could not run the hook -- too old for the loader shim, or
|
|
77
|
-
# it crashed. That used to vanish into a stderr no runtime shows (TS5 handoff
|
|
78
|
-
# review, finding N1). No flags: the entry reads configuration and silences
|
|
79
|
-
# its own warnings (US3, D3).
|
|
80
|
-
"$NODE" "$ENTRY" "$HOOK" "$@"
|
|
81
|
-
status=$?
|
|
82
|
-
if [ "$status" -ne 0 ]; then
|
|
83
|
-
note "node exited $status before the hook finished ($NODE, $("$NODE" --version 2>/dev/null || echo 'version unknown')); Mirror needs Node 24 or later. Set MIRROR_NODE."
|
|
84
|
-
fi
|
|
85
|
-
exit 0
|
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# Mirror Mind hook wrapper — GENERATED by scripts/generate_hook_wrappers.sh.
|
|
3
|
-
# Edit the generator, never this file: every wrapper shares these bytes and
|
|
4
|
-
# differs only in the hook name and in how it finds the Mirror entry
|
|
5
|
-
# (CV22.DS10.TS5 decision D4; CV22.DS10.US3 decision D5).
|
|
6
|
-
set -uo pipefail
|
|
7
|
-
HOOK="codex:session-start"
|
|
8
|
-
|
|
9
|
-
# Resolve the tree from THIS FILE, never from the cwd: a runtime spawns hooks
|
|
10
|
-
# from whatever directory the session is in. The tree is a checkout or the
|
|
11
|
-
# installed package; the wrapper's depth to its root is the same in both.
|
|
12
|
-
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
|
|
13
|
-
REPO_ROOT="$(cd "$HERE/../.." && pwd -P)"
|
|
14
|
-
|
|
15
|
-
# A hook never fails the user's turn, and never fails in silence: when it
|
|
16
|
-
# cannot do its job it appends ONE line to <mirror home>/hooks.log and exits 0.
|
|
17
|
-
# The home is found the way the core finds it -- MIRROR_HOME, else
|
|
18
|
-
# MIRROR_USER -- from the environment when set there, else from the tree's
|
|
19
|
-
# .env when the wrapper is in a tree, else from the user's config file
|
|
20
|
-
# (~/.config/mirror/env), in the core's order (US3, D3). A GUI launch carries
|
|
21
|
-
# neither variable, and the first wrappers then wrote to a home nobody reads.
|
|
22
|
-
env_file_value() {
|
|
23
|
-
[ -f "$2" ] || return 0
|
|
24
|
-
sed -n -E "s/^[[:space:]]*(export[[:space:]]+)?$1[[:space:]]*=[[:space:]]*//p" "$2" \
|
|
25
|
-
| tail -n 1 | tr -d "\"'\r"
|
|
26
|
-
}
|
|
27
|
-
env_value() {
|
|
28
|
-
local value=""
|
|
29
|
-
if [ -n "${!1+set}" ]; then printf '%s' "${!1}"; return; fi
|
|
30
|
-
[ -z "$REPO_ROOT" ] || value="$(env_file_value "$1" "$REPO_ROOT/.env")"
|
|
31
|
-
[ -n "$value" ] || value="$(env_file_value "$1" "${XDG_CONFIG_HOME:-$HOME/.config}/mirror/env")"
|
|
32
|
-
printf '%s' "$value"
|
|
33
|
-
}
|
|
34
|
-
note() {
|
|
35
|
-
local home user
|
|
36
|
-
home="$(env_value MIRROR_HOME)"
|
|
37
|
-
user="$(env_value MIRROR_USER)"
|
|
38
|
-
if [ -n "$home" ]; then
|
|
39
|
-
home="${home/#\~/$HOME}"
|
|
40
|
-
elif [ -n "$user" ]; then
|
|
41
|
-
home="$HOME/.mirror-minds/$user"
|
|
42
|
-
[ -d "$home" ] || [ ! -d "$HOME/.mirror/$user" ] || home="$HOME/.mirror/$user"
|
|
43
|
-
else
|
|
44
|
-
return 0
|
|
45
|
-
fi
|
|
46
|
-
mkdir -p "$home" 2>/dev/null \
|
|
47
|
-
&& printf '%s %s: %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$HOOK" "$1" >> "$home/hooks.log" 2>/dev/null
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
ENTRY="$REPO_ROOT/bin/mirror-hook.js"
|
|
51
|
-
|
|
52
|
-
# Find Node. /usr/bin/python3 exists on every macOS; node usually lives under
|
|
53
|
-
# nvm or Homebrew and is NOT on the PATH of a GUI-launched runtime. The Python
|
|
54
|
-
# hooks could end in `|| true` because they always found their interpreter;
|
|
55
|
-
# the same silence here would hide a new failure — no logging, no inject, no
|
|
56
|
-
# close tail — that a user would meet weeks later as "Mirror stopped
|
|
57
|
-
# remembering". So an unresolvable Node is RECORDED, never swallowed. The
|
|
58
|
-
# directory the entry was found in is searched first: an npm global bin holds
|
|
59
|
-
# the `node` that installed the package beside `mirror-hook`, while a system
|
|
60
|
-
# `/usr/local/bin/node` can be years older (CI's Ubuntu runner has one).
|
|
61
|
-
NODE="${MIRROR_NODE:-}"
|
|
62
|
-
if [ -z "$NODE" ] || [ ! -x "$NODE" ]; then
|
|
63
|
-
NODE="$(command -v node 2>/dev/null || true)"
|
|
64
|
-
fi
|
|
65
|
-
for candidate in "$(dirname "$ENTRY")/node" "$HOME/.nvm/current/bin/node" /opt/homebrew/bin/node /usr/local/bin/node; do
|
|
66
|
-
[ -n "$NODE" ] && break
|
|
67
|
-
[ -x "$candidate" ] && NODE="$candidate"
|
|
68
|
-
done
|
|
69
|
-
|
|
70
|
-
if [ -z "$NODE" ]; then
|
|
71
|
-
note "node not found on PATH; hook skipped. Set MIRROR_NODE."
|
|
72
|
-
exit 0
|
|
73
|
-
fi
|
|
74
|
-
|
|
75
|
-
# Not `exec`: the entry exits 0 whatever happens inside it, so a non-zero exit
|
|
76
|
-
# means Node itself could not run the hook -- too old for the loader shim, or
|
|
77
|
-
# it crashed. That used to vanish into a stderr no runtime shows (TS5 handoff
|
|
78
|
-
# review, finding N1). No flags: the entry reads configuration and silences
|
|
79
|
-
# its own warnings (US3, D3).
|
|
80
|
-
"$NODE" "$ENTRY" "$HOOK" "$@"
|
|
81
|
-
status=$?
|
|
82
|
-
if [ "$status" -ne 0 ]; then
|
|
83
|
-
note "node exited $status before the hook finished ($NODE, $("$NODE" --version 2>/dev/null || echo 'version unknown')); Mirror needs Node 24 or later. Set MIRROR_NODE."
|
|
84
|
-
fi
|
|
85
|
-
exit 0
|
package/scripts/codex-mirror.sh
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# Run Codex with Mirror Mind logging around it.
|
|
3
|
-
#
|
|
4
|
-
# Codex has no hook system, so this wraps the `codex` command: session start
|
|
5
|
-
# before it, and after it the JSONL Codex wrote is imported, the session is
|
|
6
|
-
# closed, and a silent backup is taken.
|
|
7
|
-
#
|
|
8
|
-
# Every Mirror call goes through a generated hook wrapper under
|
|
9
|
-
# scripts/codex-hooks/, the way every other runtime's hooks do (CV22.DS10.TS5
|
|
10
|
-
# handoff review, finding N3). The wrappers resolve Node explicitly, never fail
|
|
11
|
-
# the session, and record anything they could not do in <mirror home>/hooks.log.
|
|
12
|
-
# This script used to call the front door itself, as a command held in a string
|
|
13
|
-
# that a checkout path with a space split in two -- silently.
|
|
14
|
-
set -euo pipefail
|
|
15
|
-
|
|
16
|
-
HOOKS="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)/codex-hooks"
|
|
17
|
-
|
|
18
|
-
# The marker's mtime is how the session this run wrote is told from older ones.
|
|
19
|
-
MARKER="$(mktemp)"
|
|
20
|
-
trap 'rm -f "$MARKER"' EXIT
|
|
21
|
-
|
|
22
|
-
cd "${CODEX_PROJECT_DIR:-$PWD}"
|
|
23
|
-
|
|
24
|
-
# 1. Session start.
|
|
25
|
-
bash "$HOOKS/session-start.sh" </dev/null >/dev/null 2>&1 || true
|
|
26
|
-
|
|
27
|
-
# 2. Codex itself, until the user exits. `set -e` is off around it so the
|
|
28
|
-
# wrap-up below still runs when Codex exits non-zero -- after an MCP startup
|
|
29
|
-
# failure, for example -- and its exit code is still the script's.
|
|
30
|
-
set +e
|
|
31
|
-
codex "$@"
|
|
32
|
-
EXIT_CODE=$?
|
|
33
|
-
set -e
|
|
34
|
-
|
|
35
|
-
# 3. The JSONL Codex wrote after the marker for this directory, if any:
|
|
36
|
-
# ~/.codex/sessions/YYYY/MM/DD/rollout-<time>-<uuid>.jsonl.
|
|
37
|
-
SESSION_JSONL="$(find ~/.codex/sessions -name "*.jsonl" -newer "$MARKER" \
|
|
38
|
-
-exec grep -l "\"cwd\":\"$PWD\"" {} + 2>/dev/null | sort | tail -1 || true)"
|
|
39
|
-
SESSION_ID=""
|
|
40
|
-
if [[ -n "$SESSION_JSONL" ]]; then
|
|
41
|
-
SESSION_ID="$(basename "$SESSION_JSONL" .jsonl \
|
|
42
|
-
| grep -oE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' | tail -1 || true)"
|
|
43
|
-
fi
|
|
44
|
-
|
|
45
|
-
# 4. Import and close the session when there is one, then the silent backup.
|
|
46
|
-
bash "$HOOKS/session-end.sh" "$SESSION_JSONL" "$SESSION_ID" </dev/null >/dev/null 2>&1 || true
|
|
47
|
-
|
|
48
|
-
exit "$EXIT_CODE"
|
package/ts/src/hooks/codex.ts
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
// Codex hook entries (CV22.DS10.TS5 handoff review, finding N3).
|
|
2
|
-
//
|
|
3
|
-
// Codex has no hook system, so `scripts/codex-mirror.sh` wraps the `codex`
|
|
4
|
-
// command itself and calls these through two generated wrappers under
|
|
5
|
-
// `scripts/codex-hooks/`. Until the handoff review the script called the front
|
|
6
|
-
// door directly: it found `node` on PATH alone, silenced every failure, and
|
|
7
|
-
// built its command as a string that a checkout path with a space split in
|
|
8
|
-
// two. Going through the wrappers gives it the same Node resolution and the
|
|
9
|
-
// same hooks.log record as every other runtime.
|
|
10
|
-
//
|
|
11
|
-
// The payload comes as arguments, not stdin: the wrapper, not a runtime,
|
|
12
|
-
// decides what to pass, and a path is safer as one quoted argument than
|
|
13
|
-
// embedded in hand-written JSON.
|
|
14
|
-
|
|
15
|
-
import { runFrontDoorQuietly } from "./runtime.ts";
|
|
16
|
-
|
|
17
|
-
/** Wrapper start: make sure logging is on for this session. */
|
|
18
|
-
export async function codexSessionStart(): Promise<number> {
|
|
19
|
-
await runFrontDoorQuietly("codex:session-start", ["conversation-logger", "session-start"]);
|
|
20
|
-
return 0;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Wrapper exit: import the session Codex wrote, close it, then take the silent
|
|
25
|
-
* backup. The transcript and its session id are both absent when Codex wrote
|
|
26
|
-
* no session for this directory; the backup runs either way, as it always has.
|
|
27
|
-
*/
|
|
28
|
-
export async function codexSessionEnd(transcriptPath: string, sessionId: string): Promise<number> {
|
|
29
|
-
if (transcriptPath && sessionId) {
|
|
30
|
-
await runFrontDoorQuietly("codex:session-end", [
|
|
31
|
-
"conversation-logger",
|
|
32
|
-
"backfill-codex-session",
|
|
33
|
-
transcriptPath,
|
|
34
|
-
"--interface",
|
|
35
|
-
"codex",
|
|
36
|
-
]);
|
|
37
|
-
await runFrontDoorQuietly("codex:session-end", [
|
|
38
|
-
"conversation-logger",
|
|
39
|
-
"session-end-pi",
|
|
40
|
-
sessionId,
|
|
41
|
-
]);
|
|
42
|
-
}
|
|
43
|
-
await runFrontDoorQuietly("codex:session-end", ["backup", "--silent"]);
|
|
44
|
-
return 0;
|
|
45
|
-
}
|