fix(session-end): plan chain enqueue on the plugin-path SessionEnd bootstrap (#4170)

* fix(session-end): plan chain enqueue in the plugin-path SessionEnd bootstrap

* docs(setup): document marketplace plugin-cache staleness limitation for Flow B

* chore(inventory): regenerate after dev merge

---------

Co-authored-by: gaebal-gajae <clawdbot@users.noreply.github.com>
This commit is contained in:
pangpang778andgaebal-gajae authored and GitHub committed 2026-09-29 12:50:23 +01:00
1 parent 1232152193
commit 3a47e3898a
5 files changed
+174 -17

No files matched your search

+8
View File
@@ -170,6 +170,14 @@ claude plugin update oh-my-claudecode@oh-my-claudecode
/setup
```
**Known limitation**: the plugin cache copy runs at the version you last
updated through the marketplace — hooks (including the SessionEnd chain
enqueuer) execute the cached code, not your checkout. If session-end behavior
looks stale after rebuilding, re-run `claude plugin update` first. OMC already
mitigates dangling references to older cache versions at setup time via
`scripts/repair-plugin-cache.mjs` (registry rewrite + symlink to the latest
valid cache root), but it does not re-copy content into the cache.
### Flow C: `omc setup --no-plugin` (fallback, bundled skills)
**Advantages**: Forces local bundled skills to `~/.claude/skills/`, no plugin system.
+70 -13
View File
@@ -5,15 +5,15 @@
"provenance": {
"base": "05c800f40d1ad53b42a78609d2667ef4f726808b",
"planningHead": "0a91273e61dbbd47eb0af4c02844409251e08398",
"head": "b5f923fc326c26344b8288dbc0b2c688cb33c9f1",
"sourceSha256": "3e1826809a13a4a0efbfa96c70b0cce9a29505656d943e0c5838825bda2990cf",
"head": "5ce58e58497298b6e99a23c70f311204c40bde5a",
"sourceSha256": "61520b8ca140e3673bc6815fb4b32c0f296e0e7898dc6efb7439484c431f1f9c",
"generatedAt": null,
"generator": "scripts/generate-inventory-graph.mjs"
},
"base": "05c800f40d1ad53b42a78609d2667ef4f726808b",
"planningHead": "0a91273e61dbbd47eb0af4c02844409251e08398",
"head": "b5f923fc326c26344b8288dbc0b2c688cb33c9f1",
"sourceSha256": "3e1826809a13a4a0efbfa96c70b0cce9a29505656d943e0c5838825bda2990cf",
"head": "5ce58e58497298b6e99a23c70f311204c40bde5a",
"sourceSha256": "61520b8ca140e3673bc6815fb4b32c0f296e0e7898dc6efb7439484c431f1f9c",
"counts": {
"public": {
"skills": 47,
@@ -23,13 +23,13 @@
"total": 96
},
"internal": {
"hookFiles": 329,
"hookFiles": 330,
"featureFiles": 84,
"toolFiles": 61,
"libFiles": 47,
"templateHooks": 23,
"srcFiles": 1414,
"total": 1437
"srcFiles": 1415,
"total": 1438
},
"generated": {
"distFiles": 5066,
@@ -41,7 +41,7 @@
},
"skills": 47,
"commands": 21,
"hookFiles": 329,
"hookFiles": 330,
"workflows": 8,
"agentDefinitions": 18,
"promptLikeFiles": 61
@@ -398,6 +398,7 @@
"src/hooks/session-end/__tests__/chain-enqueuer.test.ts",
"src/hooks/session-end/__tests__/cleanup-manifest.test.ts",
"src/hooks/session-end/__tests__/duplicate-notifications.test.ts",
"src/hooks/session-end/__tests__/foreground-bootstrap.test.ts",
"src/hooks/session-end/__tests__/guardrails.test.ts",
"src/hooks/session-end/__tests__/mode-state-cleanup.test.ts",
"src/hooks/session-end/__tests__/openclaw-session-end.test.ts",
@@ -6219,6 +6220,7 @@
"src/hooks/session-end/__tests__/chain-enqueuer.test.ts",
"src/hooks/session-end/__tests__/cleanup-manifest.test.ts",
"src/hooks/session-end/__tests__/duplicate-notifications.test.ts",
"src/hooks/session-end/__tests__/foreground-bootstrap.test.ts",
"src/hooks/session-end/__tests__/guardrails.test.ts",
"src/hooks/session-end/__tests__/mode-state-cleanup.test.ts",
"src/hooks/session-end/__tests__/openclaw-session-end.test.ts",
@@ -38620,6 +38622,11 @@
"kind": "hook",
"path": "src/hooks/session-end/__tests__/duplicate-notifications.test.ts"
},
{
"id": "src/hooks/session-end/__tests__/foreground-bootstrap.test.ts",
"kind": "hook",
"path": "src/hooks/session-end/__tests__/foreground-bootstrap.test.ts"
},
{
"id": "src/hooks/session-end/__tests__/guardrails.test.ts",
"kind": "hook",
@@ -64032,6 +64039,51 @@
"to": "src/notifications/index.ts",
"kind": "imports"
},
{
"from": "src/hooks/session-end/__tests__/foreground-bootstrap.test.ts",
"to": "external:child_process",
"kind": "imports"
},
{
"from": "src/hooks/session-end/__tests__/foreground-bootstrap.test.ts",
"to": "external:fs",
"kind": "imports"
},
{
"from": "src/hooks/session-end/__tests__/foreground-bootstrap.test.ts",
"to": "external:os",
"kind": "imports"
},
{
"from": "src/hooks/session-end/__tests__/foreground-bootstrap.test.ts",
"to": "external:path",
"kind": "imports"
},
{
"from": "src/hooks/session-end/__tests__/foreground-bootstrap.test.ts",
"to": "external:vitest",
"kind": "imports"
},
{
"from": "src/hooks/session-end/__tests__/foreground-bootstrap.test.ts",
"to": "src/hooks/session-end/chain-enqueuer.ts",
"kind": "imports"
},
{
"from": "src/hooks/session-end/__tests__/foreground-bootstrap.test.ts",
"to": "src/hooks/session-end/cleanup-manifest.ts",
"kind": "imports"
},
{
"from": "src/hooks/session-end/__tests__/foreground-bootstrap.test.ts",
"to": "src/hooks/session-end/foreground-bootstrap.ts",
"kind": "imports"
},
{
"from": "src/hooks/session-end/__tests__/foreground-bootstrap.test.ts",
"to": "src/hooks/session-end/worker.ts",
"kind": "imports"
},
{
"from": "src/hooks/session-end/__tests__/guardrails.test.ts",
"to": "external:fs",
@@ -64577,6 +64629,11 @@
"to": "src/lib/worktree-paths.ts",
"kind": "imports"
},
{
"from": "src/hooks/session-end/foreground-bootstrap.ts",
"to": "src/hooks/session-end/chain-enqueuer.ts",
"kind": "imports"
},
{
"from": "src/hooks/session-end/foreground-bootstrap.ts",
"to": "src/hooks/session-end/cleanup-manifest.ts",
@@ -82604,12 +82661,12 @@
}
],
"stats": {
"nodeCount": 7121,
"edgeCount": 8093,
"importEdgeCount": 6681,
"nodeCount": 7122,
"edgeCount": 8103,
"importEdgeCount": 6691,
"registerEdgeCount": 68
}
},
"inventorySha256": "b631163acbd3652003f89ebfcca76c81a69633ffa707d8e3269fd1fe83892a49",
"manifestSha256": "6bf8d25a67b6bdca18f1950bc5915f4825a7cbfab5c9665a377a9225c9e8e205"
"inventorySha256": "ab6da542a11531649af7965ae0b3a575b88ab49eb2c4eb2c865bc6ad97f425b9",
"manifestSha256": "5069b758519b84e68cb6ed5ac423646bf28f235be080b29cab0a58bbc09a6788"
}
@@ -72,9 +72,10 @@ describe('chain-enqueuer SessionEnd registration (factory third link)', () => {
expect(existsSync(templatePath)).toBe(true);
const source = readFileSync(templatePath, 'utf8');
// Must delegate to --hook=session-end (bridge), the only entry that runs
// processSessionEnd → planChainEnqueue. publishSessionEndBootstrap (the
// plugin scripts/session-end.mjs path) does not enqueue chains.
// Must delegate to --hook=session-end (bridge), which runs
// processSessionEnd → planChainEnqueue. The plugin path
// (scripts/session-end.mjs → publishSessionEndBootstrap) enqueues the
// chain itself in foreground-bootstrap.ts.
expect(source).toContain("'--hook=session-end'");
expect(source).toContain('bridge');
});
@@ -0,0 +1,84 @@
/**
* Tests for the plugin-path SessionEnd bootstrap chain wiring (defect: plugin
* installs register hooks/hooks.json → scripts/session-end.mjs →
* publishSessionEndBootstrap, which is the ONLY SessionEnd entry when plugin
* hooks are enabled — the standalone settings.json forwarder is skipped).
* The bootstrap must therefore plan the chain enqueue and merge it into the
* durable manifest payload exactly like processSessionEnd does.
*/
import { describe, it, expect, afterEach, vi } from 'vitest';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { execFileSync } from 'child_process';
vi.mock('../worker.js', () => ({ spawnSessionEndWorker: vi.fn(() => true) }));
import { publishSessionEndBootstrap } from '../foreground-bootstrap.js';
import { readSessionEndJob } from '../cleanup-manifest.js';
import { factoryStateDir } from '../chain-enqueuer.js';
import { spawnSessionEndWorker } from '../worker.js';
const tempRoots: string[] = [];
function tempDir(): string {
const dir = mkdtempSync(join(tmpdir(), 'omc-foreground-bootstrap-'));
tempRoots.push(dir);
execFileSync('git', ['init', '--quiet'], { cwd: dir, stdio: 'ignore' });
return dir;
}
afterEach(() => {
for (const dir of tempRoots) rmSync(dir, { recursive: true, force: true });
tempRoots.length = 0;
vi.clearAllMocks();
});
function writeLedger(directory: string, sessionId: string, ledger: Record<string, unknown>): void {
mkdirSync(factoryStateDir(directory), { recursive: true });
writeFileSync(join(factoryStateDir(directory), `chain-${sessionId}.json`), JSON.stringify(ledger), 'utf8');
}
function bootstrapInput(directory: string, sessionId: string) {
return {
session_id: sessionId,
transcript_path: '',
cwd: directory,
permission_mode: '',
hook_event_name: 'SessionEnd' as const,
reason: 'prompt_input_exit' as const,
};
}
describe('publishSessionEndBootstrap chain wiring (plugin session-end path)', () => {
it('merges the planned chain into the durable manifest payload when a ledger exists', async () => {
const dir = tempDir();
writeLedger(dir, 'sess-a', { intentId: 'intent-a' });
mkdirSync(join(dir, '.omc'), { recursive: true });
writeFileSync(join(dir, '.omc', 'factory-routes.json'), JSON.stringify({
'success:*': { stage: 'review', skill: 'code-review' },
}), 'utf8');
const result = await publishSessionEndBootstrap(bootstrapInput(dir, 'sess-a'));
expect(result).toEqual({ continue: true });
const manifest = readSessionEndJob(dir, 'sess-a');
expect(manifest).not.toBeNull();
const chain = manifest?.actions['spawn-next']?.payload?.chain as Record<string, unknown> | undefined;
expect(chain).toBeDefined();
expect(chain?.outcome).toBe('success');
expect(chain?.intentId).toBe('intent-a');
// The detached worker must be launched so the spawn-next action executes.
expect(spawnSessionEndWorker).toHaveBeenCalled();
});
it('records no chain payload when the session has no factory ledger', async () => {
const dir = tempDir();
await publishSessionEndBootstrap(bootstrapInput(dir, 'sess-b'));
const manifest = readSessionEndJob(dir, 'sess-b');
expect(manifest).not.toBeNull();
expect(manifest?.actions['spawn-next']?.payload?.chain).toBeUndefined();
expect(spawnSessionEndWorker).toHaveBeenCalled();
});
});
@@ -1,4 +1,5 @@
import { prepareCoreManifest } from './cleanup-manifest.js';
import { planChainEnqueue } from './chain-enqueuer.js';
import { resolveToWorktreeRoot, validateSessionId } from '../../lib/worktree-paths.js';
export interface SessionEndBootstrapInput { session_id: string; transcript_path: string; cwd: string; permission_mode: string; hook_event_name: 'SessionEnd'; reason: 'clear' | 'logout' | 'prompt_input_exit' | 'other'; }
@@ -15,7 +16,13 @@ export interface SessionEndBootstrapResult { continue: true; }
export async function publishSessionEndBootstrap(input: SessionEndBootstrapInput): Promise<SessionEndBootstrapResult> {
validateSessionId(input.session_id);
const directory = resolveToWorktreeRoot(input.cwd);
const payload = { transcriptPath: input.transcript_path, cwd: input.cwd, reason: input.reason, input, initialTeamNames: [] };
// Plugin installs route SessionEnd here (hooks/hooks.json → scripts/session-end.mjs),
// so the chain enqueue must happen on this path — the standalone settings.json
// forwarder (processSessionEnd) is not registered when plugin hooks are enabled.
const chain = planChainEnqueue(directory, input.session_id, input.reason);
const payload = chain
? { transcriptPath: input.transcript_path, cwd: input.cwd, reason: input.reason, input, initialTeamNames: [], chain }
: { transcriptPath: input.transcript_path, cwd: input.cwd, reason: input.reason, input, initialTeamNames: [] };
const prepared = prepareCoreManifest(directory, input.session_id, payload);
if (prepared) {
const { spawnSessionEndWorker } = await import('./worker.js');