Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion claude-code/hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/bundle/session-notifications.js\"",
"timeout": 5
"timeout": 8
},
{
"type": "command",
Expand Down
6 changes: 4 additions & 2 deletions openclaw/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1146,14 +1146,15 @@ export default definePluginEntry({
const ts = new Date().toISOString();
const safe = goalsTable.replace(/[^A-Za-z0-9_]/g, "");
await dl.query(
`INSERT INTO "${safe}" (id, goal_id, owner, status, content, version, created_at, agent, plugin_version) VALUES (` +
`INSERT INTO "${safe}" (id, goal_id, owner, status, content, version, created_at, updated_at, agent, plugin_version) VALUES (` +
`'${crypto.randomUUID()}', ` +
`'${sqlStr(goalId)}', ` +
`'${sqlStr(owner)}', ` +
`'opened', ` +
`E'${sqlStr(params.text)}', ` +
`1, ` +
`'${sqlStr(ts)}', ` +
`'${sqlStr(ts)}', ` +
`'openclaw', ` +
`''` +
`)`
Expand Down Expand Up @@ -1198,13 +1199,14 @@ export default definePluginEntry({
const ts = new Date().toISOString();
const safe = kpisTable.replace(/[^A-Za-z0-9_]/g, "");
await dl.query(
`INSERT INTO "${safe}" (id, goal_id, kpi_id, content, version, created_at, agent, plugin_version) VALUES (` +
`INSERT INTO "${safe}" (id, goal_id, kpi_id, content, version, created_at, updated_at, agent, plugin_version) VALUES (` +
`'${crypto.randomUUID()}', ` +
`'${sqlStr(params.goal_id)}', ` +
`'${sqlStr(params.kpi_id)}', ` +
`E'${sqlStr(content)}', ` +
`1, ` +
`'${sqlStr(ts)}', ` +
`'${sqlStr(ts)}', ` +
`'openclaw', ` +
`''` +
`)`
Expand Down
10 changes: 6 additions & 4 deletions src/commands/goal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,14 +109,15 @@ async function goalAdd(text: string, agent: string = "manual"): Promise<void> {
const goalId = randomUUID();
const ts = new Date().toISOString();
await query(
`INSERT INTO "${safe}" (id, goal_id, owner, status, content, version, created_at, agent, plugin_version) VALUES (` +
`INSERT INTO "${safe}" (id, goal_id, owner, status, content, version, created_at, updated_at, agent, plugin_version) VALUES (` +
`'${randomUUID()}', ` +
`'${sqlStr(goalId)}', ` +
`'${sqlStr(cfg.userName)}', ` +
`'opened', ` +
`E'${sqlStr(text)}', ` +
`1, ` +
`'${sqlStr(ts)}', ` +
`'${sqlStr(ts)}', ` +
`'${sqlStr(agent)}', ` +
`''` +
`)`
Expand Down Expand Up @@ -188,7 +189,7 @@ async function goalProgress(goalId: string, status: string): Promise<void> {
const safe = sqlIdent(cfg.goalsTableName);
const ts = new Date().toISOString();
await query(
`UPDATE "${safe}" SET status = '${sqlStr(status)}', created_at = '${sqlStr(ts)}' WHERE goal_id = '${sqlStr(goalId)}'`
`UPDATE "${safe}" SET status = '${sqlStr(status)}', updated_at = '${sqlStr(ts)}' WHERE goal_id = '${sqlStr(goalId)}'`
);
process.stdout.write(`${goalId} -> ${status}\n`);
}
Expand All @@ -215,13 +216,14 @@ async function kpiAdd(args: string[]): Promise<void> {
const content = `${name}\n\n- target: ${target}\n- current: 0\n- unit: ${unit}`;
const ts = new Date().toISOString();
await query(
`INSERT INTO "${safe}" (id, goal_id, kpi_id, content, version, created_at, agent, plugin_version) VALUES (` +
`INSERT INTO "${safe}" (id, goal_id, kpi_id, content, version, created_at, updated_at, agent, plugin_version) VALUES (` +
`'${randomUUID()}', ` +
`'${sqlStr(goalId)}', ` +
`'${sqlStr(kpiId)}', ` +
`E'${sqlStr(content)}', ` +
`1, ` +
`'${sqlStr(ts)}', ` +
`'${sqlStr(ts)}', ` +
`'manual', ` +
`''` +
`)`
Expand Down Expand Up @@ -284,7 +286,7 @@ async function kpiBump(goalId: string, kpiId: string, deltaStr: string): Promise
}
const ts = new Date().toISOString();
await query(
`UPDATE "${safe}" SET content = E'${sqlStr(newContent)}', created_at = '${sqlStr(ts)}' WHERE goal_id = '${sqlStr(goalId)}' AND kpi_id = '${sqlStr(kpiId)}'`
`UPDATE "${safe}" SET content = E'${sqlStr(newContent)}', updated_at = '${sqlStr(ts)}' WHERE goal_id = '${sqlStr(goalId)}' AND kpi_id = '${sqlStr(kpiId)}'`
);
process.stdout.write(`${goalId}/${kpiId} +${delta}\n`);
}
Expand Down
2 changes: 2 additions & 0 deletions src/deeplake-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ export const GOALS_COLUMNS: readonly ColumnDef[] = Object.freeze([
{ name: "content", sql: "TEXT NOT NULL DEFAULT ''" },
{ name: "version", sql: "BIGINT NOT NULL DEFAULT 1" },
{ name: "created_at", sql: "TEXT NOT NULL DEFAULT ''" },
{ name: "updated_at", sql: "TEXT NOT NULL DEFAULT ''" },
{ name: "agent", sql: "TEXT NOT NULL DEFAULT 'manual'" },
{ name: "plugin_version", sql: "TEXT NOT NULL DEFAULT ''" },
]);
Expand Down Expand Up @@ -168,6 +169,7 @@ export const KPIS_COLUMNS: readonly ColumnDef[] = Object.freeze([
{ name: "content", sql: "TEXT NOT NULL DEFAULT ''" },
{ name: "version", sql: "BIGINT NOT NULL DEFAULT 1" },
{ name: "created_at", sql: "TEXT NOT NULL DEFAULT ''" },
{ name: "updated_at", sql: "TEXT NOT NULL DEFAULT ''" },
{ name: "agent", sql: "TEXT NOT NULL DEFAULT 'manual'" },
{ name: "plugin_version", sql: "TEXT NOT NULL DEFAULT ''" },
]);
Expand Down
8 changes: 8 additions & 0 deletions src/hooks/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
shouldTrigger,
tryAcquireLock,
releaseLock,
ensureSessionOwner,
} from "./summary-state.js";
import { bundleDirFromImportMeta, spawnWikiWorker, wikiLog } from "./spawn-wiki-worker.js";
import { tryStopCounterTrigger } from "../skillify/triggers.js";
Expand Down Expand Up @@ -79,6 +80,13 @@ async function main(): Promise<void> {
const config = loadConfig();
if (!config) { log("no config"); return; }

// Self-heal the owner record for sessions that were already open before this
// shipped (SessionStart only records it for new sessions). One /proc walk on
// the first event where the record is missing; a no-op thereafter.
if (input.session_id && process.env.HIVEMIND_WIKI_WORKER !== "1") {
ensureSessionOwner(input.session_id);
}

const sessionsTable = config.sessionsTableName;
const api = new DeeplakeApi(config.token, config.apiUrl, config.orgId, config.workspaceId, sessionsTable);

Expand Down
2 changes: 1 addition & 1 deletion src/hooks/codex/spawn-wiki-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ Format: **entity** (type) — what was done with it, its current state>
<Anything unresolved, blocked, or explicitly deferred>

## Next Steps
<The single concrete next action to resume with, as one imperative line (e.g. "Wire the resume-brief Next Steps fallback and run the tests"). If the session reached a clean stopping point with nothing pending, write exactly: none>
<Default to writing exactly: none. Most sessions are DONE when they end — do not invent a next step just to fill this section. Only name one when the session left GENUINELY SUBSTANTIVE, non-obvious work that a knowledgeable engineer returning later would actually need flagged. Make a real judgment: is there unfinished work truly WORTH resuming? If not, write: none. Write none when the work reached a natural stopping point, is complete, only trivial/obvious follow-ups remain, the "next step" is just continuing an open-ended exploration, or the only thing left is administrative wrap-up (committing, pushing, opening/merging a PR, deploying, monitoring CI) — treat all such wrap-up as ALREADY DONE. When a next step IS warranted, write a single concrete imperative line for the substantive work (e.g. "Wire the resume-brief Next Steps fallback and run the tests"). Administrative actions qualify ONLY when the session's core purpose itself was that release/ops task.>

IMPORTANT: Be exhaustive. Extract EVERY entity, decision, and fact.
PRIVACY: Never include absolute filesystem paths in the summary.
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/cursor/spawn-wiki-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ Format: **entity** (type) — what was done with it, its current state>
<Anything unresolved, blocked, or explicitly deferred>

## Next Steps
<The single concrete next action to resume with, as one imperative line (e.g. "Wire the resume-brief Next Steps fallback and run the tests"). If the session reached a clean stopping point with nothing pending, write exactly: none>
<Default to writing exactly: none. Most sessions are DONE when they end — do not invent a next step just to fill this section. Only name one when the session left GENUINELY SUBSTANTIVE, non-obvious work that a knowledgeable engineer returning later would actually need flagged. Make a real judgment: is there unfinished work truly WORTH resuming? If not, write: none. Write none when the work reached a natural stopping point, is complete, only trivial/obvious follow-ups remain, the "next step" is just continuing an open-ended exploration, or the only thing left is administrative wrap-up (committing, pushing, opening/merging a PR, deploying, monitoring CI) — treat all such wrap-up as ALREADY DONE. When a next step IS warranted, write a single concrete imperative line for the substantive work (e.g. "Wire the resume-brief Next Steps fallback and run the tests"). Administrative actions qualify ONLY when the session's core purpose itself was that release/ops task.>

IMPORTANT: Be exhaustive. Extract EVERY entity, decision, and fact.
PRIVACY: Never include absolute filesystem paths in the summary.
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/hermes/spawn-wiki-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ Format: **entity** (type) — what was done with it, its current state>
<Anything unresolved, blocked, or explicitly deferred>

## Next Steps
<The single concrete next action to resume with, as one imperative line (e.g. "Wire the resume-brief Next Steps fallback and run the tests"). If the session reached a clean stopping point with nothing pending, write exactly: none>
<Default to writing exactly: none. Most sessions are DONE when they end — do not invent a next step just to fill this section. Only name one when the session left GENUINELY SUBSTANTIVE, non-obvious work that a knowledgeable engineer returning later would actually need flagged. Make a real judgment: is there unfinished work truly WORTH resuming? If not, write: none. Write none when the work reached a natural stopping point, is complete, only trivial/obvious follow-ups remain, the "next step" is just continuing an open-ended exploration, or the only thing left is administrative wrap-up (committing, pushing, opening/merging a PR, deploying, monitoring CI) — treat all such wrap-up as ALREADY DONE. When a next step IS warranted, write a single concrete imperative line for the substantive work (e.g. "Wire the resume-brief Next Steps fallback and run the tests"). Administrative actions qualify ONLY when the session's core purpose itself was that release/ops task.>

IMPORTANT: Be exhaustive. Extract EVERY entity, decision, and fact.
PRIVACY: Never include absolute filesystem paths in the summary.
Expand Down
9 changes: 9 additions & 0 deletions src/hooks/pre-tool-use.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
} from "./query-cache.js";
import { isSafe, touchesMemory, rewritePaths } from "./memory-path-utils.js";
import { capOutputForClaude } from "../utils/output-cap.js";
import { ensureSessionOwner } from "./summary-state.js";

export { isSafe, touchesMemory, rewritePaths };

Expand Down Expand Up @@ -523,6 +524,14 @@ export async function processPreToolUse(input: PreToolUseInput, deps: ClaudePreT
/* c8 ignore start */
async function main(): Promise<void> {
const input = await readStdin<PreToolUseInput>();
// Self-heal the owner record from a SYNCHRONOUS hook. SessionStart records it
// for new sessions, but a session already open when this shipped only gets a
// record via the async capture hook — which can be detached and unable to
// walk to its `claude` ancestor. PreToolUse runs synchronously under claude,
// so its /proc walk reliably finds the owner; no-op once recorded.
if (input.session_id && process.env.HIVEMIND_WIKI_WORKER !== "1") {
try { ensureSessionOwner(input.session_id); } catch { /* best-effort */ }
}
const decision = await processPreToolUse(input);
if (!decision) return;
if (decision.deny !== undefined) {
Expand Down
7 changes: 6 additions & 1 deletion src/hooks/session-end.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { readStdin } from "../utils/stdin.js";
import { loadConfig, type Config } from "../config.js";
import { log as _log } from "../utils/debug.js";
import { bundleDirFromImportMeta, spawnWikiWorker, wikiLog } from "./spawn-wiki-worker.js";
import { tryAcquireLock, releaseLock } from "./summary-state.js";
import { tryAcquireLock, releaseLock, markSessionEnded } from "./summary-state.js";
import { forceSessionEndTrigger } from "../skillify/triggers.js";
import { parseTranscript } from "../notifications/transcript-parser.js";
import { appendUsageRecord } from "../notifications/usage-tracker.js";
Expand Down Expand Up @@ -59,6 +59,11 @@ async function main(): Promise<void> {
const cwd = input.cwd ?? "";
if (!sessionId) return;

// Mark this session cleanly ended so another session's resume brief stops
// treating it as live and may surface it immediately (without waiting for
// the activity window to lapse). Independent of the wiki-worker lock below.
markSessionEnded(sessionId);

const config = loadConfig();
if (!config) { log("no config"); return; }

Expand Down
7 changes: 6 additions & 1 deletion src/hooks/session-notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ const log = (msg: string) => _log("session-notifications", msg);
interface SessionStartInput {
session_id?: string;
cwd?: string;
/** "startup" | "resume" | "clear" | "compact" — Claude Code tells us why the
* session started. The "where you left off" banner is only meaningful on a
* fresh startup; on a resume you already have the thread, so we suppress it. */
source?: string;
}

async function main(): Promise<void> {
Expand All @@ -49,9 +53,10 @@ async function main(): Promise<void> {
// letting an empty string slip through.
const rawSessionId = typeof input?.session_id === "string" ? input.session_id.trim() : "";
const sessionId = rawSessionId.length > 0 ? rawSessionId : undefined;
const source = typeof input?.source === "string" ? input.source : undefined;

const creds = loadCredentials();
await drainSessionStart({ agent: "claude-code", creds, sessionId });
await drainSessionStart({ agent: "claude-code", creds, sessionId, source });
}

main().catch((e) => { log(`fatal: ${e?.message ?? String(e)}`); process.exit(0); });
13 changes: 13 additions & 0 deletions src/hooks/session-start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { maybeAutoMineLocal } from "../skillify/spawn-mine-local-worker.js";
import { graphContextLine } from "../graph/session-context.js";
import { spawnGraphPullWorker } from "../graph/spawn-pull-worker.js";
import { entrypointPassesOnlyCliGate } from "./shared/capture-gate.js";
import { clearSessionEnded, recordSessionOwner, touchSessionActivity } from "./summary-state.js";
const log = (msg: string) => _log("session-start", msg);

const __bundleDir = dirname(fileURLToPath(import.meta.url));
Expand Down Expand Up @@ -141,6 +142,18 @@ async function main(): Promise<void> {

const input = await readStdin<SessionStartInput>();

// A fresh start or --resume of this session re-activates it: drop any stale
// ended marker and record the owning `claude` process so other sessions can
// tell this one is live even while it sits idle waiting on the user.
if (input.session_id) {
clearSessionEnded(input.session_id);
recordSessionOwner(input.session_id);
// Re-arm the heartbeat so the non-Linux mtime fallback in isSessionLive()
// marks this resumed session live right away, not only after its first
// captured event (on Linux the owner record above already does this).
touchSessionActivity(input.session_id);
}
Comment on lines +145 to +155
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Re-arm the heartbeat fallback on SessionStart.

On non-Linux, Line 150 is a no-op, so isSessionLive() falls back to the existing state-file mtime. If the user resumes an older session and then stays idle, that mtime can already be outside activeWindowMs(), and another terminal can immediately surface this still-open session in the resume banner until the first new event arrives. Touch the session’s activity timestamp here as well so the documented non-Linux fallback actually marks resumed sessions live.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/session-start.ts` around lines 145 - 151, When starting/resuming a
session, also update the session activity timestamp so the non-Linux mtime
fallback used by isSessionLive() / activeWindowMs() marks the resumed session as
live; specifically, in the SessionStart block after
clearSessionEnded(input.session_id) and recordSessionOwner(input.session_id) add
a call to the helper that updates the session activity mtime (e.g.
touchSessionActivity or updateSessionActivityTimestamp) for input.session_id —
if that helper does not exist, implement it to persistently update the session’s
activity timestamp in the same state storage isSessionLive() reads.


let creds = loadCredentials();

if (!creds?.token) {
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/spawn-wiki-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ Example: "- The memory table uses DELETE+INSERT, not UPDATE (WASM doesn't suppor
<Anything unresolved, blocked, or explicitly deferred>

## Next Steps
<The single concrete next action to resume with, as one imperative line (e.g. "Wire the resume-brief Next Steps fallback and run the tests"). If the session reached a clean stopping point with nothing pending, write exactly: none>
<Default to writing exactly: none. Most sessions are DONE when they end — do not invent a next step just to fill this section. Only name one when the session left GENUINELY SUBSTANTIVE, non-obvious work that a knowledgeable engineer returning later would actually need flagged. Make a real judgment: is there unfinished work truly WORTH resuming? If not, write: none. Write none when the work reached a natural stopping point, is complete, only trivial/obvious follow-ups remain, the "next step" is just continuing an open-ended exploration, or the only thing left is administrative wrap-up (committing, pushing, opening/merging a PR, deploying, monitoring CI) — treat all such wrap-up as ALREADY DONE. When a next step IS warranted, write a single concrete imperative line for the substantive work (e.g. "Wire the resume-brief Next Steps fallback and run the tests"). Administrative actions qualify ONLY when the session's core purpose itself was that release/ops task.>

IMPORTANT: Be exhaustive. Extract EVERY entity, decision, and fact. Future you will search this wiki to answer questions like "who worked on X", "why did we choose Y", "what's the status of Z". If a detail exists in the session, it should be in the wiki.

Expand Down
Loading
Loading