Skip to content
Merged
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
115 changes: 113 additions & 2 deletions src/commands/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import type { Command } from 'commander'
import { printJson, printTable, runAction } from '../lib/output'
import { formatTs, printJson, printTable, runAction } from '../lib/output'
import { toInt } from '../lib/args'
import {
claudeSettingsPath,
enrollRepo,
enrolledRepos,
hookStatsPath,
hooksInstalled,
installHooks,
readHookStats,
readSyncLog,
repoFromCwd,
spooledPendingCount,
syncLogPath,
unenrollRepo,
uninstallHooks,
type HookSyncStats,
} from '../lib/laptop'

// `agent hooks …` — manage the Claude Code hooks + per-repo enrollment that
Expand Down Expand Up @@ -76,8 +83,14 @@ export function registerHooks(program: Command): void {
runAction(async () => {
const installed = hooksInstalled()
const enrolled = enrolledRepos()
const stats = readHookStats()
if (opts.json) {
printJson({ settings: claudeSettingsPath(), hooks: installed, enrolled_repos: enrolled })
printJson({
settings: claudeSettingsPath(),
hooks: installed,
enrolled_repos: enrolled,
sync_stats: stats ?? null,
})
return
}
printTable(
Expand All @@ -87,6 +100,91 @@ export function registerHooks(program: Command): void {
console.log('')
if (enrolled.length === 0) console.log('Enrolled repositories: none')
else printTable(['ENROLLED REPOSITORY'], enrolled.map((r) => [r]))
if (stats?.last_sync_at) {
console.log('')
console.log(
`Last sync ${ago(stats.last_sync_at)} (${stats.last_outcome}) · ` +
`${stats.synced_24h} synced / ${stats.failed_24h} failed in 24h · ` +
`${spooledPendingCount()} spooled pending`,
)
}
}),
)

hooks
.command('logs')
.description('Show the activity log the background `agent session sync` hooks write')
.option('-n, --tail <n>', 'show the last N entries', toInt, 20)
.option('--failures', 'only show entries whose outcome is not "synced"')
.option('--json', 'print NDJSON (one log entry per line)')
.action(async (opts: { tail: number; failures?: boolean; json?: boolean }) =>
runAction(async () => {
let entries = readSyncLog()
if (opts.failures) entries = entries.filter((e) => e.outcome !== 'synced')
entries = entries.slice(-Math.max(0, opts.tail))
if (opts.json) {
for (const e of entries) console.log(JSON.stringify(e))
return
}
if (entries.length === 0) {
console.log(
opts.failures
? 'No sync failures logged.'
: `No sync activity logged yet (${syncLogPath()}).`,
)
return
}
printTable(
['TIME', 'OUTCOME', 'REPO', 'REASON', 'DETAIL'],
entries.map((e) => [
formatTs(e.ts),
e.outcome,
e.repo ?? '—',
e.reason ?? '—',
e.outcome === 'synced'
? `${e.event_count ?? '?'} events → ${e.session_id ?? '?'}`
: e.error ?? '',
]),
)
}),
)

hooks
.command('stats')
.description('Show sync stats from the plain-JSON stats object the hooks maintain')
.option('--json', 'print the raw stats object')
.action(async (opts: { json?: boolean }) =>
runAction(async () => {
const stats = readHookStats()
if (!stats) {
if (opts.json) {
printJson(null)
return
}
console.log(`No sync stats yet (${hookStatsPath()}). Nothing has attempted to sync.`)
return
}
// spooled_pending in the file is a snapshot from the last sync; the
// spool dir is cheap to count, so show it live.
const live: HookSyncStats = { ...stats, spooled_pending: spooledPendingCount() }
if (opts.json) {
printJson(live)
return
}
console.log(`stats file: ${hookStatsPath()}`)
console.log(
`last sync: ${live.last_sync_at ? `${formatTs(live.last_sync_at)} (${ago(live.last_sync_at)})` : '—'}`,
)
console.log(`last outcome: ${live.last_outcome ?? '—'}`)
if (live.last_error) console.log(`last error: ${live.last_error}`)
console.log(`synced (24h): ${live.synced_24h}`)
console.log(`failed (24h): ${live.failed_24h}`)
console.log(`spooled pending: ${live.spooled_pending}`)
console.log(`total synced: ${live.total_synced}`)
if (live.recent_session_ids.length) {
console.log('recent sessions:')
for (const id of live.recent_session_ids) console.log(` ${id}`)
}
}),
)

Expand Down Expand Up @@ -116,3 +214,16 @@ export function registerHooks(program: Command): void {
}),
)
}

// Coarse relative time ("4m ago") for the status/stats one-liners.
export function ago(iso: string): string {
const ms = Date.now() - Date.parse(iso)
if (!Number.isFinite(ms) || ms < 0) return iso
const s = Math.floor(ms / 1000)
if (s < 60) return `${s}s ago`
const m = Math.floor(s / 60)
if (m < 60) return `${m}m ago`
const h = Math.floor(m / 60)
if (h < 24) return `${h}h ago`
return `${Math.floor(h / 24)}d ago`
}
44 changes: 33 additions & 11 deletions src/commands/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,11 @@ import {
enrolledRepos,
listSpooledSyncs,
pushHandoffRef,
recordSyncOutcome,
redactLine,
repoFromCwd,
spoolSync,
type SyncOutcome,
} from '../lib/laptop'
import { ApiError } from '../lib/api'
import { resolveToken } from '../lib/config'
Expand Down Expand Up @@ -625,9 +627,6 @@ async function syncTranscript(opts: {
// Hook mode = driven by CC (stdin context, no explicit flags): all failure
// paths are silent no-ops so they never surface into the session.
const hookMode = hook !== undefined && !opts.transcript && !opts.sessionId
const quit = (message: string): void => {
if (!hookMode) throw new Error(message)
}

const ccSessionId = opts.sessionId ?? hook?.session_id
const transcriptPath = opts.transcript ?? hook?.transcript_path
Expand All @@ -638,20 +637,33 @@ async function syncTranscript(opts: {
: hook?.hook_event_name === 'SessionEnd'
? 'session_end'
: 'stop'
const repo = repoFromCwd(cwd)

// Hook mode is quiet on every failure path, so the local activity log
// (hooks/sync.log.jsonl + stats.json, surfaced by `agent hooks logs/stats`)
// is the only place a failed background sync is observable. Recording is
// best-effort and never throws, preserving the exit-0 guarantee.
const quit = (outcome: SyncOutcome, message: string): void => {
recordSyncOutcome({ outcome, cc_session_id: ccSessionId, repo, reason, error: message })
if (!hookMode) throw new Error(message)
}

if (!ccSessionId || !transcriptPath) {
return quit('need --session-id and --transcript (or hook JSON on stdin)')
return quit('rejected', 'need --session-id and --transcript (or hook JSON on stdin)')
}

// Consent gate: per-repo opt-in, silently skipped otherwise.
const repo = repoFromCwd(cwd)
if (!repo || !enrolledRepos().includes(repo.toLowerCase())) {
return quit(`repository ${repo ?? `at ${cwd}`} is not enrolled (agent hooks enroll)`)
return quit(
'skipped_unenrolled',
`repository ${repo ?? `at ${cwd}`} is not enrolled (agent hooks enroll)`,
)
}
if (!resolveToken()) {
return quit('not logged in. Run `agent login` first, or set ELLIPSIS_API_TOKEN.')
return quit('not_logged_in', 'not logged in. Run `agent login` first, or set ELLIPSIS_API_TOKEN.')
}
if (!existsSync(transcriptPath)) {
return quit(`transcript not found: ${transcriptPath}`)
return quit('no_transcript', `transcript not found: ${transcriptPath}`)
}

// Redact line-by-line (secrets never leave the laptop unredacted), then
Expand All @@ -660,7 +672,9 @@ async function syncTranscript(opts: {
.split('\n')
.filter((l) => l.trim().length > 0)
.map(redactLine)
if (lines.length === 0) return quit(`transcript is empty: ${transcriptPath}`)
if (lines.length === 0) {
return quit('no_transcript', `transcript is empty: ${transcriptPath}`)
}

const req: SyncAgentSessionRequest = {
cc_session_id: ccSessionId,
Expand All @@ -674,6 +688,14 @@ async function syncTranscript(opts: {
const api = new ApiClient()
try {
const res = await api.syncAgentSession(req)
recordSyncOutcome({
outcome: 'synced',
cc_session_id: ccSessionId,
repo,
reason,
session_id: res.session_id,
event_count: res.event_count,
})
if (opts.json) printJson(res)
else if (!hookMode) {
console.log(
Expand All @@ -686,11 +708,11 @@ async function syncTranscript(opts: {
// Spool (latest snapshot per session wins) and stay quiet in hook mode —
// the next sync flushes it.
spoolSync(req)
if (!hookMode) throw err
quit('spooled', (err as Error).message)
return
}
// Permanent rejection (auth, validation, payload too large): never spool.
if (!hookMode) throw err
quit('rejected', (err as Error).message)
return
}

Expand Down
Loading
Loading