mirror-mind 0.34.0 → 0.36.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/.pi/skills/mm-build/SKILL.md +1 -1
- package/.pi/skills/mm-explore/SKILL.md +1 -1
- package/.pi/skills/mm-help/SKILL.md +2 -2
- package/.pi/skills/mm-soul/SKILL.md +1 -1
- package/AGENTS.md +7 -6
- package/README.md +5 -6
- package/docs/releases/index.md +2 -0
- package/docs/releases/v0.35.0.md +199 -0
- package/docs/releases/v0.36.0.md +222 -0
- package/package.json +2 -4
- package/plugins/mirror-mind/.claude-plugin/plugin.json +1 -1
- package/plugins/mirror-mind/agy/pre-invocation.sh +92 -0
- package/plugins/mirror-mind/agy/session-start.sh +92 -0
- package/plugins/mirror-mind/agy/stop.sh +92 -0
- package/plugins/mirror-mind/hooks.json +26 -0
- package/plugins/mirror-mind/mcp_config.json +11 -0
- package/plugins/mirror-mind/plugin.json +14 -0
- package/plugins/mirror-mind/rules/AGENTS.md +189 -0
- package/plugins/mirror-mind/skills/mm-build/SKILL.md +1 -1
- package/ts/src/conversation/agyTranscript.ts +105 -0
- package/ts/src/conversation/commandTurn.ts +1 -1
- package/ts/src/conversation/deferredCloseTail.ts +2 -2
- package/ts/src/conversation/logger.ts +1 -1
- package/ts/src/conversation/runtimePolicy.ts +36 -4
- package/ts/src/docs/docsLint.ts +14 -1
- package/ts/src/extensions/bindings.ts +53 -2
- package/ts/src/extensions/catalogCommands.ts +82 -23
- package/ts/src/extensions/catalogWrites.ts +12 -0
- package/ts/src/extensions/contextRuntime.ts +36 -1
- package/ts/src/extensions/dispatch.ts +43 -10
- package/ts/src/extensions/extensionPlugin.ts +325 -0
- package/ts/src/extensions/manifest.ts +7 -1
- package/ts/src/extensions/settings.ts +147 -0
- package/ts/src/frontDoor/cli.ts +16 -1
- package/ts/src/frontDoor/extensionCatalogRoute.ts +8 -2
- package/ts/src/frontDoor/runtimeRoute.ts +11 -0
- package/ts/src/frontDoor/stdinSource.ts +2 -2
- package/ts/src/guards/claudePlugin.ts +91 -0
- package/ts/src/guards/packContents.ts +3 -2
- package/ts/src/guards/piSourcedSkills.ts +1 -1
- package/ts/src/guards/retiredSurfaces.ts +31 -0
- package/ts/src/hooks/agy.ts +229 -0
- package/ts/src/hooks/claude.ts +6 -0
- package/ts/src/hooks/main.ts +23 -16
- package/ts/src/hooks/payload.ts +8 -15
- package/ts/src/hooks/runtime.ts +11 -10
- package/ts/src/runtime/brokenPipe.ts +52 -0
- package/ts/src/runtime/diagnose.ts +122 -13
- package/ts/src/runtime/extensionPluginWiring.ts +222 -0
- package/ts/src/runtime/pluginWiring.ts +168 -29
- package/ts/src/runtime/status.ts +28 -1
- package/ts/src/runtime/update.ts +25 -11
- package/ts/src/runtime/wiring.ts +149 -35
- package/.gemini/hooks/log-assistant.sh +0 -85
- package/.gemini/hooks/log-user.sh +0 -85
- package/.gemini/hooks/session-end.sh +0 -85
- package/.gemini/hooks/session-start.sh +0 -85
- package/ts/src/hooks/gemini.ts +0 -128
|
@@ -178,6 +178,88 @@ export function marketplaceJson(): string {
|
|
|
178
178
|
return `${JSON.stringify(buildMarketplace(), null, 2)}\n`;
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
// --- the agy-native layer (CV21.DS7.US1) -------------------------------------
|
|
182
|
+
//
|
|
183
|
+
// Antigravity treats a directory as a NATIVE plugin only when a root
|
|
184
|
+
// `plugin.json` marks it (or `.antigravity-plugin/`/`.gemini-plugin/`).
|
|
185
|
+
// Without the marker, `agy plugin install` converts the Claude surfaces as an
|
|
186
|
+
// extension and OVERWRITES a shipped root `hooks.json` with Claude's — the
|
|
187
|
+
// probe on agy 1.4.2 showed both behaviors (evidence in the US1 story
|
|
188
|
+
// package). With the marker, validation passes, our root `hooks.json`
|
|
189
|
+
// survives staging, `mcpServers: 1 processed`, and the 25 skills ride along.
|
|
190
|
+
//
|
|
191
|
+
// The layer is four generated files at agy's documented paths: the marker,
|
|
192
|
+
// the hooks skeleton (TS2 adds injection, TS3 adds logging), the MCP map
|
|
193
|
+
// (DERIVED from the manifest's entry — one locator, every carrier), and
|
|
194
|
+
// `rules/AGENTS.md` (agy merges `rules/` into the active rule set; the guard
|
|
195
|
+
// comparing committed to generated bytes is the sync with `AGENTS.md`).
|
|
196
|
+
//
|
|
197
|
+
// The marker is also what GROK reads (CV21.DS10.US1). Grok accepts a root
|
|
198
|
+
// `plugin.json` and the `.claude-plugin/` equivalent, and takes the root one
|
|
199
|
+
// first and alone: with a name-only marker, `grok plugin validate` dropped
|
|
200
|
+
// "MCP servers" from the components it had listed on 1.0.52, `plugin list`
|
|
201
|
+
// reported `version: null`, and no shape of install offered the MCP server
|
|
202
|
+
// (probe on grok 1.0.62, 2026-10-18, story package). So the marker carries
|
|
203
|
+
// the manifest's `version` and `mcpServers`, from the same source; agy 1.4.2
|
|
204
|
+
// warns `unknown field "mcpServers" is ignored` and stages the same tree.
|
|
205
|
+
// Hooks stay out of it: agy reads the root `hooks.json` beside it.
|
|
206
|
+
|
|
207
|
+
/** The native-plugin marker: agy's detection, and the manifest Grok reads. */
|
|
208
|
+
export function buildAgyPluginMarker(version: string): Record<string, unknown> {
|
|
209
|
+
return {
|
|
210
|
+
name: PLUGIN_NAME,
|
|
211
|
+
description: PLUGIN_DESCRIPTION,
|
|
212
|
+
version,
|
|
213
|
+
mcpServers: {
|
|
214
|
+
[PLUGIN_NAME]: { command: MCP_ENTRY.command, args: [...MCP_ENTRY.args] },
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function agyPluginMarkerJson(version: string): string {
|
|
220
|
+
return `${JSON.stringify(buildAgyPluginMarker(version), null, 2)}\n`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The named hook with its handlers (CV21.DS7.TS2). Commands are relative:
|
|
225
|
+
* agy sets a handler's cwd to the directory holding `hooks.json`, and the
|
|
226
|
+
* wrappers live beside it in `agy/` (generated agy-form; the pre-invocation
|
|
227
|
+
* wrapper gates on stdin before Node is ever resolved, because
|
|
228
|
+
* `PreInvocation` fires before every model call and blocks the agent loop).
|
|
229
|
+
* Timeouts are explicit and small: the fast path is shell-instant, the slow
|
|
230
|
+
* path is one `mirror load`, and a hang here freezes the agent — which a
|
|
231
|
+
* user reads as "Antigravity broke", not "Mirror hung". `Stop` is TS3's.
|
|
232
|
+
*/
|
|
233
|
+
export function buildAgyHooks(): Record<string, unknown> {
|
|
234
|
+
return {
|
|
235
|
+
[PLUGIN_NAME]: {
|
|
236
|
+
enabled: true,
|
|
237
|
+
SessionStart: [{ type: "command", command: "agy/session-start.sh", timeout: 60 }],
|
|
238
|
+
PreInvocation: [{ type: "command", command: "agy/pre-invocation.sh", timeout: 15 }],
|
|
239
|
+
// CV21.DS7.TS3: Stop fires at each turn's end (agy has no SessionEnd);
|
|
240
|
+
// the handler reads the transcript and logs the turn as antigravity.
|
|
241
|
+
Stop: [{ type: "command", command: "agy/stop.sh", timeout: 30 }],
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function agyHooksJson(): string {
|
|
247
|
+
return `${JSON.stringify(buildAgyHooks(), null, 2)}\n`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** agy's `mcp_config.json`: the same `mcpServers` map the manifest carries. */
|
|
251
|
+
export function buildAgyMcpConfig(): Record<string, unknown> {
|
|
252
|
+
return {
|
|
253
|
+
mcpServers: {
|
|
254
|
+
[PLUGIN_NAME]: { command: MCP_ENTRY.command, args: [...MCP_ENTRY.args] },
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function agyMcpConfigJson(): string {
|
|
260
|
+
return `${JSON.stringify(buildAgyMcpConfig(), null, 2)}\n`;
|
|
261
|
+
}
|
|
262
|
+
|
|
181
263
|
/**
|
|
182
264
|
* The skill markdown file, matching `skill.md` case-insensitively.
|
|
183
265
|
*
|
|
@@ -269,6 +351,15 @@ export function planGeneratedFiles(repoRoot: string): GeneratedFile[] {
|
|
|
269
351
|
content: manifestJson(version),
|
|
270
352
|
},
|
|
271
353
|
{ relativePath: MARKETPLACE_PATH, content: marketplaceJson() },
|
|
354
|
+
// The agy-native layer (CV21.DS7.US1).
|
|
355
|
+
{ relativePath: join(PLUGIN_DIR, "plugin.json"), content: agyPluginMarkerJson(version) },
|
|
356
|
+
{ relativePath: join(PLUGIN_DIR, "hooks.json"), content: agyHooksJson() },
|
|
357
|
+
{ relativePath: join(PLUGIN_DIR, "mcp_config.json"), content: agyMcpConfigJson() },
|
|
358
|
+
{
|
|
359
|
+
relativePath: join(PLUGIN_DIR, "rules", "AGENTS.md"),
|
|
360
|
+
content: readFileSync(join(repoRoot, "AGENTS.md"), "utf8"),
|
|
361
|
+
source: "AGENTS.md",
|
|
362
|
+
},
|
|
272
363
|
...claudeCopies.values(),
|
|
273
364
|
...[...pluginSkills].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([, file]) => file),
|
|
274
365
|
];
|
|
@@ -26,7 +26,6 @@ export const ALLOWED_PREFIXES: readonly string[] = [
|
|
|
26
26
|
".pi/extensions/",
|
|
27
27
|
".pi/skills/",
|
|
28
28
|
".claude/hooks/",
|
|
29
|
-
".gemini/hooks/",
|
|
30
29
|
"plugins/mirror-mind/",
|
|
31
30
|
];
|
|
32
31
|
|
|
@@ -56,8 +55,10 @@ export const REQUIRED_FILES: readonly string[] = [
|
|
|
56
55
|
".pi/extensions/mirror-logger.ts",
|
|
57
56
|
".pi/skills/mm-mirror/SKILL.md",
|
|
58
57
|
".claude/hooks/session-start.sh",
|
|
59
|
-
".gemini/hooks/session-start.sh",
|
|
60
58
|
"plugins/mirror-mind/hooks/hooks.json",
|
|
59
|
+
// CV21.DS7.US1: the agy-native layer's hooks file. A tarball cannot ship
|
|
60
|
+
// the skills without the layer Antigravity runs them beside.
|
|
61
|
+
"plugins/mirror-mind/hooks.json",
|
|
61
62
|
".agents/plugins/marketplace.json",
|
|
62
63
|
];
|
|
63
64
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Skills with one body in every runtime (CR102).
|
|
2
2
|
//
|
|
3
3
|
// Most Mirror skills have two authored bodies. `.pi/skills/` serves Pi, and
|
|
4
|
-
// through the `.agents/skills/` links Codex
|
|
4
|
+
// through the `.agents/skills/` links Codex too. `.claude/skills/`
|
|
5
5
|
// serves Claude Code, and `claudePlugin.ts` copies it into the plugin. CR071
|
|
6
6
|
// kept the two apart on purpose: Usage sections, example language, and in
|
|
7
7
|
// `mm-soul` and `mm-mirror` whole lines that are true for one runtime only.
|
|
@@ -514,6 +514,37 @@ export const RETIRED: readonly RetiredSurface[] = [
|
|
|
514
514
|
residueScope: ["ts/src/", "ts/scripts/"],
|
|
515
515
|
exemptions: {},
|
|
516
516
|
},
|
|
517
|
+
{
|
|
518
|
+
surfaceId: "gemini-cli",
|
|
519
|
+
story: "CV21.DS8.US1",
|
|
520
|
+
// The Gemini CLI integration: four generated wrappers under `.gemini/hooks`,
|
|
521
|
+
// the `gemini:*` hook family behind them, the two `mirror init` steps, and
|
|
522
|
+
// the Gemini smoke. Retired once DS7.US4 measured Antigravity at L4 on agy
|
|
523
|
+
// 1.4.2 -- a Gemini CLI user loses nothing by moving. `.gemini/` is an
|
|
524
|
+
// absent path and not merely an unallowed one because `mirror init` told
|
|
525
|
+
// users to write absolute paths into that directory into their
|
|
526
|
+
// `~/.gemini/settings.json`: a standing instruction to execute whatever a
|
|
527
|
+
// later release might ship there. This row is what keeps that from
|
|
528
|
+
// happening.
|
|
529
|
+
absentPaths: [".gemini/", "ts/src/hooks/gemini.ts", "scripts/smoke_gemini_cli.sh"],
|
|
530
|
+
// File and route names, never vocabulary: "gemini" is a living word (the
|
|
531
|
+
// extraction and consult models, Antigravity's own `~/.gemini/` home, the
|
|
532
|
+
// `gemini_cli` label on conversations already logged). The row landed in
|
|
533
|
+
// two steps: US1 carried the names only code and CI used, and
|
|
534
|
+
// CV21.DS8.US2 added the doc-facing ones when it cleared the runtime-facing
|
|
535
|
+
// docs, so no exemption was ever staged. Not `.gemini/settings.json` and
|
|
536
|
+
// not `~/.gemini`: that directory is Antigravity's home too, and the
|
|
537
|
+
// cutoff note names the file a Gemini CLI user edits.
|
|
538
|
+
forbiddenPatterns: [
|
|
539
|
+
"smoke_gemini_cli",
|
|
540
|
+
"geminiHooksSettings",
|
|
541
|
+
"hooks/gemini\\.ts",
|
|
542
|
+
"\\.gemini/hooks",
|
|
543
|
+
"gemini:(session-start|session-end|log-user|log-assistant)",
|
|
544
|
+
"gemini skills link",
|
|
545
|
+
],
|
|
546
|
+
exemptions: {},
|
|
547
|
+
},
|
|
517
548
|
];
|
|
518
549
|
|
|
519
550
|
export interface SurfaceProblem {
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// Antigravity hook entries (CV21.DS7.TS2).
|
|
2
|
+
//
|
|
3
|
+
// agy runs the plugin's `hooks.json` handlers with camelCase protojson on
|
|
4
|
+
// stdin and reads JSON from stdout (`injectSteps`; a `systemMessage` step
|
|
5
|
+
// nests its text, an `ephemeralMessage` is a plain string). The contracts
|
|
6
|
+
// are committed bytes in CV21.DS7.US1's `evidence/` (agy 1.4.2).
|
|
7
|
+
//
|
|
8
|
+
// Two handlers:
|
|
9
|
+
//
|
|
10
|
+
// * `agy:session-start` — turn conversation logging on, the posture every
|
|
11
|
+
// runtime's session-start shares, and answer `{}`. The Operating
|
|
12
|
+
// Instructions are NOT delivered here: `rules/AGENTS.md` carries them
|
|
13
|
+
// (US1), unless TS2's live route shows otherwise — the measured
|
|
14
|
+
// fallback would add an `injectSteps` `systemMessage` in this handler.
|
|
15
|
+
//
|
|
16
|
+
// * `agy:pre-invocation` — inject Mirror Mode context once per user turn.
|
|
17
|
+
// `PreInvocation` fires before EVERY model call (32 times over two
|
|
18
|
+
// turns, DS12.TS3 L2), with `invocationNum` resetting to 0 at each user
|
|
19
|
+
// turn; protojson omits zero-valued fields, so an ABSENT invocationNum
|
|
20
|
+
// is 0. The generated wrapper already gates on this before Node is
|
|
21
|
+
// spawned; the gate here is defense in depth. The injection is an
|
|
22
|
+
// `ephemeralMessage` — dropped after the turn — for two reasons: the
|
|
23
|
+
// adapter re-injects per turn, so a persistent step would accumulate;
|
|
24
|
+
// and the content is identity and memory, so ephemeral is the
|
|
25
|
+
// data-minimizing choice against agy's stored trajectory.
|
|
26
|
+
//
|
|
27
|
+
// agy payloads carry no prompt or reply text (L7): there is no `--query`,
|
|
28
|
+
// and the explicit Mirror form reaches the model through the skill (L4),
|
|
29
|
+
// never through these hooks. The quiet session — a `conversationId` no
|
|
30
|
+
// runtime session binds (every session until TS3 lands), or one bound with
|
|
31
|
+
// Mirror Mode off — answers `{}`: Mirror on agy is invisible until invoked.
|
|
32
|
+
//
|
|
33
|
+
// Payload fields stay inert: `conversationId` flows only through array
|
|
34
|
+
// argv, never a shell, and failures recorded in hooks.log are facts, never
|
|
35
|
+
// payload text.
|
|
36
|
+
|
|
37
|
+
import { readAgyTurns } from "#conversation/agyTranscript.ts";
|
|
38
|
+
import { isCommandTurn } from "#conversation/commandTurn.ts";
|
|
39
|
+
import { logAssistantMessage, logUserMessage } from "#conversation/logger.ts";
|
|
40
|
+
import { resolveDbPath } from "#frontDoor/dbPath.ts";
|
|
41
|
+
import { openLiveWriteDatabase } from "#frontDoor/liveBackup.ts";
|
|
42
|
+
import { decodeMetadata, getRuntimeSession, upsertRuntimeSession } from "#mirror/runtimeSession.ts";
|
|
43
|
+
import { newId, nowIso } from "#util/pyGenerators.ts";
|
|
44
|
+
import { readMirrorState } from "./mirrorState.ts";
|
|
45
|
+
import { noteHookFailure, runFrontDoor, runFrontDoorQuietly } from "./runtime.ts";
|
|
46
|
+
|
|
47
|
+
/** agy's empty answer, which every path that injects nothing must print. */
|
|
48
|
+
const EMPTY = "{}\n";
|
|
49
|
+
|
|
50
|
+
interface AgyPayload {
|
|
51
|
+
readonly conversationId: string;
|
|
52
|
+
readonly invocationNum: number;
|
|
53
|
+
readonly transcriptPath: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The key under which the Stop handler stores its high-water step_index. */
|
|
57
|
+
const HIGH_WATER_KEY = "agy_last_step_index";
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Parse agy's camelCase protojson payload. Anything malformed reads as the
|
|
61
|
+
* empty payload: a hook never fails the turn over stdin it did not write.
|
|
62
|
+
*/
|
|
63
|
+
export function parseAgyPayload(stdin: string): AgyPayload {
|
|
64
|
+
try {
|
|
65
|
+
const raw: unknown = JSON.parse(stdin);
|
|
66
|
+
if (typeof raw !== "object" || raw === null) {
|
|
67
|
+
return { conversationId: "", invocationNum: 0, transcriptPath: "" };
|
|
68
|
+
}
|
|
69
|
+
const record = raw as Record<string, unknown>;
|
|
70
|
+
return {
|
|
71
|
+
conversationId: typeof record.conversationId === "string" ? record.conversationId : "",
|
|
72
|
+
// protojson omits zero-valued fields: absent IS 0.
|
|
73
|
+
invocationNum: typeof record.invocationNum === "number" ? record.invocationNum : 0,
|
|
74
|
+
transcriptPath: typeof record.transcriptPath === "string" ? record.transcriptPath : "",
|
|
75
|
+
};
|
|
76
|
+
} catch {
|
|
77
|
+
return { conversationId: "", invocationNum: 0, transcriptPath: "" };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* `SessionStart`: logging on, activate Mirror Mode for the session, answer
|
|
83
|
+
* the contract.
|
|
84
|
+
*
|
|
85
|
+
* Mirror Mode activation (CV21.DS7.US4 scope change). agy's `PreInvocation`
|
|
86
|
+
* carries no prompt text (DS12.TS3 L7), so the injection hook cannot detect
|
|
87
|
+
* the Mirror form to self-activate, and the `/mirror-mind:mm:*` skill runs
|
|
88
|
+
* `mirror load` as an agent command that cannot bind the agy session id. So
|
|
89
|
+
* SessionStart — the one hook that carries the `conversationId` at the start
|
|
90
|
+
* of the session — sets `mirror_active`, and the per-turn `PreInvocation`
|
|
91
|
+
* injection (built in TS2, gated on that flag) fires from then on. This
|
|
92
|
+
* matches Gemini CLI's always-on per-turn injection, the runtime Antigravity
|
|
93
|
+
* replaces; a session that never ran SessionStart stays quiet, which the
|
|
94
|
+
* negative probe pins.
|
|
95
|
+
*/
|
|
96
|
+
export async function agySessionStart(stdin: string): Promise<number> {
|
|
97
|
+
await runFrontDoorQuietly("agy:session-start", ["conversation-logger", "session-start"]);
|
|
98
|
+
|
|
99
|
+
const conversationId = parseAgyPayload(stdin).conversationId;
|
|
100
|
+
if (conversationId) {
|
|
101
|
+
try {
|
|
102
|
+
const dbPath = resolveDbPath([], process.env);
|
|
103
|
+
const db = openLiveWriteDatabase(dbPath);
|
|
104
|
+
try {
|
|
105
|
+
upsertRuntimeSession(
|
|
106
|
+
db,
|
|
107
|
+
conversationId,
|
|
108
|
+
{ interface: "antigravity", mirrorActive: true },
|
|
109
|
+
nowIso(),
|
|
110
|
+
);
|
|
111
|
+
} finally {
|
|
112
|
+
db.close();
|
|
113
|
+
}
|
|
114
|
+
} catch (error) {
|
|
115
|
+
noteHookFailure("agy:session-start", error instanceof Error ? error.message : String(error));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
process.stdout.write(EMPTY);
|
|
120
|
+
return 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* `Stop`: fires at the end of each user turn (agy has no SessionEnd hook).
|
|
125
|
+
* Read the transcript, append the turns past the stored high-water
|
|
126
|
+
* `step_index` as `antigravity`, and leave the conversation open — session
|
|
127
|
+
* maintenance closes it as a stale orphan, the path Codex uses (Shape A).
|
|
128
|
+
*
|
|
129
|
+
* A re-fired Stop, or a grown transcript whose turns are all logged, adds
|
|
130
|
+
* nothing: the high-water mark is the idempotency key. A skill turn is
|
|
131
|
+
* skipped, the one-home rule. Any failure is recorded in hooks.log, never
|
|
132
|
+
* fails the turn, and the handler always answers agy's contract.
|
|
133
|
+
*/
|
|
134
|
+
export async function agyStop(stdin: string): Promise<number> {
|
|
135
|
+
const payload = parseAgyPayload(stdin);
|
|
136
|
+
if (!payload.conversationId || !payload.transcriptPath) {
|
|
137
|
+
process.stdout.write(EMPTY);
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const turns = readAgyTurns(payload.transcriptPath);
|
|
142
|
+
if (turns.length === 0) {
|
|
143
|
+
process.stdout.write(EMPTY);
|
|
144
|
+
return 0;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let dbPath: string;
|
|
148
|
+
try {
|
|
149
|
+
dbPath = resolveDbPath([], process.env);
|
|
150
|
+
} catch {
|
|
151
|
+
process.stdout.write(EMPTY);
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
const db = openLiveWriteDatabase(dbPath);
|
|
157
|
+
try {
|
|
158
|
+
const sessionId = payload.conversationId;
|
|
159
|
+
const existing = getRuntimeSession(db, sessionId);
|
|
160
|
+
const stored = decodeMetadata(existing?.metadata ?? null);
|
|
161
|
+
const highWater =
|
|
162
|
+
typeof stored[HIGH_WATER_KEY] === "number" ? (stored[HIGH_WATER_KEY] as number) : -1;
|
|
163
|
+
const deps = { newId, nowIso };
|
|
164
|
+
const options = { interface: "antigravity" };
|
|
165
|
+
|
|
166
|
+
let newHighWater = highWater;
|
|
167
|
+
for (const turn of turns) {
|
|
168
|
+
if (turn.lastStepIndex <= highWater) continue;
|
|
169
|
+
if (!isCommandTurn(turn.userText)) {
|
|
170
|
+
logUserMessage(db, sessionId, turn.userText, options, deps);
|
|
171
|
+
logAssistantMessage(db, sessionId, turn.replyText, options, deps);
|
|
172
|
+
}
|
|
173
|
+
if (turn.lastStepIndex > newHighWater) newHighWater = turn.lastStepIndex;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (newHighWater !== highWater) {
|
|
177
|
+
const current = getRuntimeSession(db, sessionId);
|
|
178
|
+
const metadata = decodeMetadata(current?.metadata ?? null);
|
|
179
|
+
metadata[HIGH_WATER_KEY] = newHighWater;
|
|
180
|
+
upsertRuntimeSession(db, sessionId, { metadata: JSON.stringify(metadata) }, nowIso());
|
|
181
|
+
}
|
|
182
|
+
} finally {
|
|
183
|
+
db.close();
|
|
184
|
+
}
|
|
185
|
+
} catch (error) {
|
|
186
|
+
noteHookFailure("agy:stop", error instanceof Error ? error.message : String(error));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
process.stdout.write(EMPTY);
|
|
190
|
+
return 0;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** `PreInvocation`: one ephemeral context step per user turn, when owed. */
|
|
194
|
+
export async function agyPreInvocation(stdin: string): Promise<number> {
|
|
195
|
+
const payload = parseAgyPayload(stdin);
|
|
196
|
+
|
|
197
|
+
// Defense in depth behind the wrapper's shell gate: only the turn's first
|
|
198
|
+
// invocation injects; the other ~30 answer instantly.
|
|
199
|
+
if (payload.invocationNum !== 0 || !payload.conversationId) {
|
|
200
|
+
process.stdout.write(EMPTY);
|
|
201
|
+
return 0;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
let dbPath: string;
|
|
205
|
+
try {
|
|
206
|
+
dbPath = resolveDbPath([], process.env);
|
|
207
|
+
} catch {
|
|
208
|
+
process.stdout.write(EMPTY);
|
|
209
|
+
return 0;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// The quiet session: unbound, or bound with Mirror Mode off.
|
|
213
|
+
const state = readMirrorState(dbPath, payload.conversationId);
|
|
214
|
+
if (!state.active) {
|
|
215
|
+
process.stdout.write(EMPTY);
|
|
216
|
+
return 0;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const argv = ["mirror", "load", "--context-only", "--session-id", payload.conversationId];
|
|
220
|
+
if (state.persona) argv.push("--persona", state.persona);
|
|
221
|
+
if (state.journey) argv.push("--journey", state.journey);
|
|
222
|
+
|
|
223
|
+
const result = await runFrontDoor(argv);
|
|
224
|
+
const context = result.stdout.trim() ? result.stdout : "";
|
|
225
|
+
process.stdout.write(
|
|
226
|
+
context ? `${JSON.stringify({ injectSteps: [{ ephemeralMessage: context }] })}\n` : EMPTY,
|
|
227
|
+
);
|
|
228
|
+
return 0;
|
|
229
|
+
}
|
package/ts/src/hooks/claude.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// for every prompt and the logger decides), a missing session id still fails
|
|
12
12
|
// loud, and every failure is still non-fatal to the user's turn.
|
|
13
13
|
|
|
14
|
+
import { runtimeReadsPromptContext } from "#conversation/runtimePolicy.ts";
|
|
14
15
|
import { resolveDbPath } from "#frontDoor/dbPath.ts";
|
|
15
16
|
import { openLiveWriteDatabase } from "#frontDoor/liveBackup.ts";
|
|
16
17
|
import { mirrorForm } from "#mirror/mirrorForm.ts";
|
|
@@ -124,6 +125,11 @@ export async function claudeSessionEnd(stdin: string): Promise<number> {
|
|
|
124
125
|
*/
|
|
125
126
|
export async function claudeInject(stdin: string): Promise<number> {
|
|
126
127
|
const payload = parseHookPayload(stdin);
|
|
128
|
+
// CR143: on a runtime that discards what this hook returns (Grok), neither
|
|
129
|
+
// case is worth its load — nor the state read below, nor the "injected"
|
|
130
|
+
// mark, which would say the model received what it did not. Detected the
|
|
131
|
+
// way the sibling hooks detect it, from the payload and the environment.
|
|
132
|
+
if (!runtimeReadsPromptContext(detectHookRuntime(payload.raw))) return 0;
|
|
127
133
|
const sessionId = payload.sessionId;
|
|
128
134
|
|
|
129
135
|
// Case 1: the explicit invocation.
|
package/ts/src/hooks/main.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
//
|
|
5
5
|
// CV22.DS10.TS5 plateau 1, decision D4. Twelve shell scripts across four
|
|
6
6
|
// runtimes each spawned the interpreter one to five times per event. They now
|
|
7
|
-
// run one Node process and name which hook they are
|
|
7
|
+
// run one Node process and name which hook they are (the Gemini CLI family,
|
|
8
|
+
// `gemini:*`, retired with CV21.DS8.US1; Antigravity is its successor):
|
|
8
9
|
//
|
|
9
10
|
// node ts/src/hooks/main.ts claude:inject
|
|
10
11
|
//
|
|
@@ -34,14 +35,10 @@
|
|
|
34
35
|
// why this file must exit 0: the wrappers read any other exit as Node failing.
|
|
35
36
|
|
|
36
37
|
import { pathToFileURL } from "node:url";
|
|
38
|
+
import { exitQuietlyOnBrokenPipe } from "#runtime/brokenPipe.ts";
|
|
37
39
|
import { loadConfiguration, silenceExperimentalWarnings } from "#runtime/config.ts";
|
|
40
|
+
import { agyPreInvocation, agySessionStart, agyStop } from "./agy.ts";
|
|
38
41
|
import { claudeHook } from "./claude.ts";
|
|
39
|
-
import {
|
|
40
|
-
geminiLogAssistant,
|
|
41
|
-
geminiLogUser,
|
|
42
|
-
geminiSessionEnd,
|
|
43
|
-
geminiSessionStart,
|
|
44
|
-
} from "./gemini.ts";
|
|
45
42
|
import { readStdin } from "./payload.ts";
|
|
46
43
|
import { noteHookFailure } from "./runtime.ts";
|
|
47
44
|
|
|
@@ -51,16 +48,16 @@ async function dispatch(name: string): Promise<number> {
|
|
|
51
48
|
switch (runtime) {
|
|
52
49
|
case "claude":
|
|
53
50
|
return claudeHook(hook ?? "");
|
|
54
|
-
case "
|
|
51
|
+
case "agy":
|
|
52
|
+
// The Antigravity plugin's handlers (CV21.DS7.TS2), called through the
|
|
53
|
+
// generated agy-form wrappers in `plugins/mirror-mind/agy/`.
|
|
55
54
|
switch (hook) {
|
|
56
|
-
case "log-user":
|
|
57
|
-
return geminiLogUser(readStdin());
|
|
58
|
-
case "log-assistant":
|
|
59
|
-
return geminiLogAssistant(readStdin());
|
|
60
|
-
case "session-end":
|
|
61
|
-
return geminiSessionEnd(readStdin());
|
|
62
55
|
case "session-start":
|
|
63
|
-
return
|
|
56
|
+
return agySessionStart(readStdin());
|
|
57
|
+
case "pre-invocation":
|
|
58
|
+
return agyPreInvocation(readStdin());
|
|
59
|
+
case "stop":
|
|
60
|
+
return agyStop(readStdin());
|
|
64
61
|
default:
|
|
65
62
|
break;
|
|
66
63
|
}
|
|
@@ -81,8 +78,18 @@ async function dispatch(name: string): Promise<number> {
|
|
|
81
78
|
*/
|
|
82
79
|
export async function runAsEntry(rawArgv = process.argv.slice(2)): Promise<number> {
|
|
83
80
|
silenceExperimentalWarnings();
|
|
84
|
-
loadConfiguration({ entryPath: import.meta.filename });
|
|
85
81
|
const name = rawArgv[0] ?? "";
|
|
82
|
+
// The try/catch below is the last line of defense, and it has one hole: a
|
|
83
|
+
// stream's `error` event is not a throw in that `await` chain. A runtime
|
|
84
|
+
// that gives up on a hook closes its pipe, and until CR148 the hook answered
|
|
85
|
+
// the next write with an unhandled EPIPE, a stack trace into the host's log,
|
|
86
|
+
// and exit 1 -- the one exit code this entry promises never to return. One
|
|
87
|
+
// line in `hooks.log`, then the quiet exit the contract requires.
|
|
88
|
+
exitQuietlyOnBrokenPipe((code) => {
|
|
89
|
+
noteHookFailure(name || "hooks", "stdout closed early");
|
|
90
|
+
process.exit(code);
|
|
91
|
+
});
|
|
92
|
+
loadConfiguration({ entryPath: import.meta.filename });
|
|
86
93
|
try {
|
|
87
94
|
return await dispatch(name);
|
|
88
95
|
} catch (error) {
|
package/ts/src/hooks/payload.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Reading a runtime's hook payload from stdin.
|
|
2
2
|
//
|
|
3
3
|
// Replaces the Python hook module `extract_prompt` (25 lines) and the four
|
|
4
|
-
// inline interpreter one-liners the Claude and Gemini hooks used to parse
|
|
5
|
-
// same JSON. None of them was a command, so none was in
|
|
4
|
+
// inline interpreter one-liners the Claude and Gemini CLI hooks used to parse
|
|
5
|
+
// the same JSON (the Gemini CLI hooks retired with CV21.DS8.US1). None of them was a command, so none was in
|
|
6
6
|
// DS7's denominator of 29 -- which is why TS5's inventory had to find them by
|
|
7
7
|
// reading the hooks rather than by counting commands.
|
|
8
8
|
//
|
|
@@ -23,17 +23,11 @@ export interface HookPayload {
|
|
|
23
23
|
readonly prompt: string;
|
|
24
24
|
/** The runtime's session id (`session_id`, or Grok's `sessionId`), or "". */
|
|
25
25
|
readonly sessionId: string;
|
|
26
|
-
/**
|
|
27
|
-
readonly response: string;
|
|
28
|
-
/** Everything else, for hooks that need a field these three do not cover. */
|
|
26
|
+
/** Everything else, for hooks that need a field these two do not cover. */
|
|
29
27
|
readonly raw: HookPayloadObject;
|
|
30
28
|
}
|
|
31
29
|
|
|
32
|
-
const EMPTY: HookPayload = { prompt: "", sessionId: "",
|
|
33
|
-
|
|
34
|
-
function text(value: unknown): string {
|
|
35
|
-
return typeof value === "string" ? value : "";
|
|
36
|
-
}
|
|
30
|
+
const EMPTY: HookPayload = { prompt: "", sessionId: "", raw: {} };
|
|
37
31
|
|
|
38
32
|
/**
|
|
39
33
|
* Parse a hook payload. Never throws: an unparseable payload is empty.
|
|
@@ -47,11 +41,10 @@ export function parseHookPayload(input: string): HookPayload {
|
|
|
47
41
|
return {
|
|
48
42
|
prompt: promptOf(raw),
|
|
49
43
|
sessionId: sessionIdOf(raw),
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
response: text(raw.prompt_response),
|
|
44
|
+
// A `response` field read Gemini CLI's `prompt_response` here until
|
|
45
|
+
// CV21.DS8.US1; no living runtime hands the reply to a hook payload by
|
|
46
|
+
// name (Claude-family hooks read the transcript, agy's Stop reads its
|
|
47
|
+
// JSONL), so the field went with its only reader.
|
|
55
48
|
raw,
|
|
56
49
|
};
|
|
57
50
|
}
|
package/ts/src/hooks/runtime.ts
CHANGED
|
@@ -61,11 +61,11 @@ export function noteHookFailure(hook: string, reason: string, env = process.env)
|
|
|
61
61
|
* The Operating Instructions a SessionStart hook should hand its runtime, or
|
|
62
62
|
* null when the project already carries this same file.
|
|
63
63
|
*
|
|
64
|
-
* CV22.DS10.US3, decision D13. An installed package reaches Claude Code and
|
|
65
|
-
* Gemini CLI through hooks, skills, and an MCP
|
|
66
|
-
*
|
|
67
|
-
* `/mm-mirror` and still route no mode, sign no persona, and hold
|
|
68
|
-
* boundary.
|
|
64
|
+
* CV22.DS10.US3, decision D13. An installed package reaches Claude Code (and
|
|
65
|
+
* reached Gemini CLI, until CV21.DS8.US1) through hooks, skills, and an MCP
|
|
66
|
+
* server; none of those reads a CLAUDE.md, so a session wired to the package
|
|
67
|
+
* would answer `/mm-mirror` and still route no mode, sign no persona, and hold
|
|
68
|
+
* no Builder boundary. The runtime reads SessionStart's
|
|
69
69
|
* `hookSpecificOutput.additionalContext`, so the session-start hook returns
|
|
70
70
|
* `AGENTS.md` from the tree THIS code runs from -- the instructions always
|
|
71
71
|
* match the core they ship with -- unless the project the session opened in
|
|
@@ -92,8 +92,9 @@ export function operatingInstructionsFor(
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
/**
|
|
95
|
-
* The SessionStart answer
|
|
96
|
-
* -- what
|
|
95
|
+
* The SessionStart answer the runtime reads: context as JSON, or `nothing`
|
|
96
|
+
* -- what the runtime's hook printed before D13 (Claude none; Gemini CLI's
|
|
97
|
+
* `{}`, until CV21.DS8.US1 retired it).
|
|
97
98
|
*/
|
|
98
99
|
export function sessionStartOutput(instructions: string | null, nothing = ""): string {
|
|
99
100
|
if (instructions === null) return nothing;
|
|
@@ -148,9 +149,9 @@ export async function runFrontDoor(
|
|
|
148
149
|
/**
|
|
149
150
|
* The command a failure line may name: the family and its subcommand, never
|
|
150
151
|
* the arguments. Arguments are where hooks put what the user said and what the
|
|
151
|
-
* model answered -- the Gemini logger
|
|
152
|
-
* argv -- and the first version of the failure
|
|
153
|
-
* handoff review, finding P1).
|
|
152
|
+
* model answered -- the Gemini CLI logger, retired by CV21.DS8.US1, passed the
|
|
153
|
+
* prompt and the response as argv -- and the first version of the failure
|
|
154
|
+
* line printed all of them (TS5 handoff review, finding P1).
|
|
154
155
|
*/
|
|
155
156
|
function commandName(argv: readonly string[]): string {
|
|
156
157
|
return argv.slice(0, 2).join(" ");
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// A closed stdout or stderr ends the command quietly (CR148).
|
|
2
|
+
//
|
|
3
|
+
// `mirror ext <id> | head -1` is a shell idiom, not an error: `head`, `tail`,
|
|
4
|
+
// a pager that quit, a `grep -q` that matched -- every one of them closes its
|
|
5
|
+
// read end on purpose. Node ignores SIGPIPE and surfaces the next write as an
|
|
6
|
+
// `error` event on the stream, and with nobody listening that event is an
|
|
7
|
+
// uncaught exception: `Unhandled 'error' event`, `Error: write EPIPE`, a
|
|
8
|
+
// stack, exit 1. Nothing of ours failed; the reader asked us to stop.
|
|
9
|
+
//
|
|
10
|
+
// One listener at each entry (`frontDoor/cli.ts`, `hooks/main.ts`), nothing
|
|
11
|
+
// per route. On EPIPE: exit 0 and write nothing. The exit status follows
|
|
12
|
+
// `npm`, `cargo`, and `kubectl`, not git's 141: under `set -o pipefail`, 141
|
|
13
|
+
// fails a pipeline the person wrote on purpose, and the fix they reach for is
|
|
14
|
+
// `|| true`, which then hides the real failures too.
|
|
15
|
+
//
|
|
16
|
+
// Stop means stop. The exit happens at the next write after the reader
|
|
17
|
+
// closes, and work the command had not done by then -- a commit after a
|
|
18
|
+
// progress line, a log after an `await` -- is abandoned, exactly as SIGPIPE
|
|
19
|
+
// abandons git's. That is the shell's contract, recorded in the CR.
|
|
20
|
+
//
|
|
21
|
+
// Anything that is not EPIPE is rethrown: a real stream failure keeps the
|
|
22
|
+
// trace it had before. On Linux pipes are synchronous and on macOS
|
|
23
|
+
// asynchronous; in both the failure arrives as this event, never as a throw
|
|
24
|
+
// from `write()`. libuv maps ERROR_BROKEN_PIPE to EPIPE on Windows.
|
|
25
|
+
|
|
26
|
+
type Exit = (code: number) => void;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Install the handler. `exit` is `process.exit` for the CLI; the hook passes
|
|
30
|
+
* one that leaves a line in `hooks.log` first, because the hook's promise
|
|
31
|
+
* that every path returns 0 cannot be kept by its try/catch -- a stream's
|
|
32
|
+
* `error` event is not a throw in that `await` chain.
|
|
33
|
+
*
|
|
34
|
+
* Called once per process by `runAsEntry`; no guard against a second call,
|
|
35
|
+
* because there is no second call, and no test runs an entry in-process.
|
|
36
|
+
*/
|
|
37
|
+
export function exitQuietlyOnBrokenPipe(
|
|
38
|
+
exit: Exit,
|
|
39
|
+
streams: readonly NodeJS.WritableStream[] = [process.stdout, process.stderr],
|
|
40
|
+
): void {
|
|
41
|
+
let exiting = false;
|
|
42
|
+
for (const stream of streams) {
|
|
43
|
+
stream.on("error", (error: NodeJS.ErrnoException) => {
|
|
44
|
+
if (error.code !== "EPIPE") throw error;
|
|
45
|
+
// Both streams on one pipe (`2>&1 | head`) fail together. A real
|
|
46
|
+
// `process.exit` never returns; an injected one must still count once.
|
|
47
|
+
if (exiting) return;
|
|
48
|
+
exiting = true;
|
|
49
|
+
exit(0);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|