{sessionName}
@@ -288,6 +297,43 @@ export function SessionRowSummary(props: {
) : null}
+ {attachedJob && jobProgressLabel ? (
+
+
+
+ {attachedJob.label}
+ · {jobProgressLabel}
+
+ {jobFraction !== null ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+ ) : null}
{projectLabel || machineLabel ? (
{[projectLabel, machineLabel].filter(Boolean).join(' · ')}
diff --git a/web/src/hooks/useSSE.test.ts b/web/src/hooks/useSSE.test.ts
index c2259ecf2c..7917422acb 100644
--- a/web/src/hooks/useSSE.test.ts
+++ b/web/src/hooks/useSSE.test.ts
@@ -179,6 +179,7 @@ function makeSummary(overrides: Partial
= {}): SessionSummary {
backgroundTaskCount: 0,
futureScheduledMessageCount: 0,
nextScheduledAt: null,
+ attachedJob: null,
model: null,
effort: null,
...overrides
diff --git a/web/src/hooks/useSSE.ts b/web/src/hooks/useSSE.ts
index 2218012083..39236f212c 100644
--- a/web/src/hooks/useSSE.ts
+++ b/web/src/hooks/useSSE.ts
@@ -159,6 +159,16 @@ export function isRenderIrrelevantPatch(current: SessionSummary, next: SessionSu
&& current.thinking === next.thinking
&& current.updatedAt === next.updatedAt
&& current.backgroundTaskCount === next.backgroundTaskCount
+ && current.attachedJob?.key === next.attachedJob?.key
+ && current.attachedJob?.label === next.attachedJob?.label
+ && current.attachedJob?.status === next.attachedJob?.status
+ && current.attachedJob?.done === next.attachedJob?.done
+ && current.attachedJob?.total === next.attachedJob?.total
+ && current.attachedJob?.remaining === next.attachedJob?.remaining
+ && current.attachedJob?.unit === next.attachedJob?.unit
+ && current.attachedJob?.detail === next.attachedJob?.detail
+ && current.attachedJob?.heartbeatAt === next.attachedJob?.heartbeatAt
+ && (current.attachedJob == null) === (next.attachedJob == null)
&& current.model === next.model
&& current.modelReasoningEffort === next.modelReasoningEffort
&& current.effort === next.effort
@@ -487,6 +497,7 @@ export function useSSE(options: {
const existing = existingIndex >= 0 ? previous.sessions[existingIndex] : undefined
const summary = {
...toSessionSummary(session),
+ attachedJob: existing?.attachedJob ?? null,
futureScheduledMessageCount: existing?.futureScheduledMessageCount ?? 0,
nextScheduledAt: existing?.nextScheduledAt ?? null
}
@@ -532,6 +543,9 @@ export function useSSE(options: {
backgroundTaskCount: Object.prototype.hasOwnProperty.call(patch, 'backgroundTaskCount')
? patch.backgroundTaskCount ?? 0
: current.backgroundTaskCount,
+ attachedJob: Object.prototype.hasOwnProperty.call(patch, 'attachedJob')
+ ? patch.attachedJob ?? null
+ : current.attachedJob ?? null,
model: Object.prototype.hasOwnProperty.call(patch, 'model') ? patch.model ?? null : current.model,
modelReasoningEffort: Object.prototype.hasOwnProperty.call(patch, 'modelReasoningEffort')
? patch.modelReasoningEffort ?? null
diff --git a/web/src/lib/attachedJob.test.ts b/web/src/lib/attachedJob.test.ts
new file mode 100644
index 0000000000..fe3946b192
--- /dev/null
+++ b/web/src/lib/attachedJob.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it } from 'vitest'
+import type { AttachedJob } from '@hapi/protocol'
+import {
+ ATTACHED_JOB_STALE_MS,
+ attachedJobFraction,
+ formatAttachedJobProgress,
+ isAttachedJobStale
+} from './attachedJob'
+
+function job(overrides: Partial = {}): AttachedJob {
+ return {
+ key: 'beets',
+ label: 'beets import',
+ status: 'running',
+ heartbeatAt: 1_000,
+ startedAt: 1_000,
+ updatedAt: 1_000,
+ ...overrides
+ }
+}
+
+describe('attachedJob helpers', () => {
+ it('formats remaining count without inventing percent', () => {
+ expect(formatAttachedJobProgress(job({ remaining: 120, unit: 'tracks' }))).toBe('120 tracks left')
+ })
+
+ it('formats done/total with derived percent', () => {
+ expect(formatAttachedJobProgress(job({ done: 800, total: 900, unit: 'tracks' }))).toBe(
+ '89% · 800/900 tracks'
+ )
+ })
+
+ it('falls back to running when only heartbeat', () => {
+ expect(formatAttachedJobProgress(job())).toBe('running')
+ })
+
+ it('computes fraction from remaining+total', () => {
+ expect(attachedJobFraction(job({ remaining: 100, total: 1000 }))).toBe(0.9)
+ })
+
+ it('marks stale after heartbeat window', () => {
+ const now = 1_000 + ATTACHED_JOB_STALE_MS + 1
+ expect(isAttachedJobStale(job({ heartbeatAt: 1_000 }), now)).toBe(true)
+ expect(isAttachedJobStale(job({ heartbeatAt: now - 60_000 }), now)).toBe(false)
+ })
+})
diff --git a/web/src/lib/attachedJob.ts b/web/src/lib/attachedJob.ts
new file mode 100644
index 0000000000..58c869c1c9
--- /dev/null
+++ b/web/src/lib/attachedJob.ts
@@ -0,0 +1,31 @@
+import type { AttachedJob } from '@hapi/protocol'
+
+/** Stale if no heartbeat for 15 minutes — UI amber, still shows progress. */
+export const ATTACHED_JOB_STALE_MS = 15 * 60 * 1000
+
+export function formatAttachedJobProgress(job: AttachedJob): string {
+ if (job.remaining !== undefined) {
+ const unit = job.unit ? ` ${job.unit}` : ''
+ return `${job.remaining}${unit} left`
+ }
+ if (job.done !== undefined && job.total !== undefined && job.total > 0) {
+ const pct = Math.min(100, Math.round((job.done / job.total) * 100))
+ return `${pct}% · ${job.done}/${job.total}${job.unit ? ` ${job.unit}` : ''}`
+ }
+ return 'running'
+}
+
+export function attachedJobFraction(job: AttachedJob): number | null {
+ if (job.done !== undefined && job.total !== undefined && job.total > 0) {
+ return Math.max(0, Math.min(1, job.done / job.total))
+ }
+ if (job.remaining !== undefined && job.total !== undefined && job.total > 0) {
+ const done = Math.max(0, job.total - job.remaining)
+ return Math.max(0, Math.min(1, done / job.total))
+ }
+ return null
+}
+
+export function isAttachedJobStale(job: AttachedJob, now: number = Date.now()): boolean {
+ return now - job.heartbeatAt > ATTACHED_JOB_STALE_MS
+}
diff --git a/web/src/lib/sessionAttention.test.ts b/web/src/lib/sessionAttention.test.ts
index acf95094b7..a503ac98a9 100644
--- a/web/src/lib/sessionAttention.test.ts
+++ b/web/src/lib/sessionAttention.test.ts
@@ -22,6 +22,7 @@ function makeSummary(overrides: Partial & { id: string }): Sessi
backgroundTaskCount: 0,
futureScheduledMessageCount: 0,
nextScheduledAt: null,
+ attachedJob: null,
model: null,
effort: null,
...overrides
diff --git a/web/src/lib/sessionReference.test.ts b/web/src/lib/sessionReference.test.ts
index 4118d4c7d9..407f296646 100644
--- a/web/src/lib/sessionReference.test.ts
+++ b/web/src/lib/sessionReference.test.ts
@@ -28,6 +28,7 @@ function makeSession(overrides: Partial & { id: string }): Sessi
backgroundTaskCount: 0,
futureScheduledMessageCount: 0,
nextScheduledAt: null,
+ attachedJob: null,
model: null,
effort: null,
...overrides,
From bba8e69db29bab7cb73b75bfda23153b3919938a Mon Sep 17 00:00:00 2001
From: HeavyGee <133152184+heavygee@users.noreply.github.com>
Date: Fri, 7 Aug 2026 11:53:38 +0000
Subject: [PATCH 05/94] feat(jobs): agent guidance + wall-clock elapsed on list
chrome
Document the session-job contract for agents and always show startedAt
elapsed next to remaining/fraction/running so indeterminate drains still
read as wall time without inventing an ETA.
Co-authored-by: Cursor
---
AGENTS.md | 15 +++
cli/README.md | 1 +
cli/src/claude/utils/systemPrompt.ts | 3 +-
cli/src/codex/utils/systemPrompt.ts | 3 +-
cli/src/commands/job.ts | 36 +++++++-
cli/src/grok/utils/systemPrompt.ts | 3 +-
.../common/sessionJobInstruction.test.ts | 20 ++++
.../modules/common/sessionJobInstruction.ts | 33 +++++++
cli/src/opencode/utils/systemPrompt.test.ts | 6 +-
cli/src/opencode/utils/systemPrompt.ts | 5 +-
docs/.vitepress/config.ts | 1 +
docs/guide/faq.md | 4 +
docs/guide/session-jobs.md | 92 +++++++++++++++++++
web/src/components/SessionRowSummary.tsx | 15 ++-
web/src/lib/attachedJob.test.ts | 33 +++++--
web/src/lib/attachedJob.ts | 38 +++++++-
16 files changed, 284 insertions(+), 24 deletions(-)
create mode 100644 cli/src/modules/common/sessionJobInstruction.test.ts
create mode 100644 cli/src/modules/common/sessionJobInstruction.ts
create mode 100644 docs/guide/session-jobs.md
diff --git a/AGENTS.md b/AGENTS.md
index 5d49368812..87bf50ae94 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -24,6 +24,7 @@ Start with the task's files; read only relevant sections of these references, no
| Shared wire types and validation | `shared/src/types.ts`, `schemas.ts`, `socket.ts`, `modes.ts` |
| Native API contract, chat conformance | [client contract](docs/api/client-contract/index.md), [iOS](ios/README.md), [Android](android/README.md) |
| Encrypted native push relay | [relay/README.md](relay/README.md) |
+| Session-attached jobs (outliving work) | [session jobs guide](docs/guide/session-jobs.md), `cli/src/commands/job.ts` |
| User docs / marketing site | `docs/` (VitePress) / `website/` |
## Repository conventions
@@ -39,6 +40,20 @@ Start with the task's files; read only relevant sections of these references, no
- Metadata/state updates are versioned; preserve stale-update rejection. Permission controls use per-flavor catalogs in `shared/src/modes.ts`, further constrained by session capabilities.
- `shared/fixtures/**` is generated from the web chat pipeline, the source of truth for native conformance. Never hand-edit fixtures. For changes to fixture inputs or generation (paths in [.github/workflows/fixtures.yml](.github/workflows/fixtures.yml)), run `bun run gen:fixtures` and include any generated changes in the deliverable. CI checks drift and runs native conformance on fixture changes.
+## Session-attached jobs (outliving work)
+
+When an agent starts process-shaped work that will keep running after the agent goes idle (`nohup`, batch imports, long scripts, external daemons), attach it so the session list stays truthful while `active: false`. This is **not** thinking progress / todos / in-agent background tools.
+
+Agent contract (idle agents cannot heartbeat — bare set + nohup freezes the bar):
+
+1. **Required for process-shaped work:** Shell `hapi job run --label … -- ` (auto-heartbeat + exit status). Use `"$HAPI_SESSION_ID"` only when it matches the operator chat row (`/sessions/` in the web URL).
+2. MCP `session_job` **refuses `action=set`**. Use it only for `update` / `clear` / `list` on a job the supervisor already created.
+3. Manual CLI `set` only with a self-heartbeating wrapper (`update` ≥~10m); never MCP set + nohup.
+4. Prefer honest `--remaining` or `--done`/`--total`; omit counts if unknown — never invent a percent.
+5. Elapsed wall clock is always shown from `startedAt` (not an ETA).
+
+Full guide: [docs/guide/session-jobs.md](docs/guide/session-jobs.md). CLI: `hapi job --help`.
+
## Verification and completion
Choose checks by the change's impact, not by the number of workflow steps:
diff --git a/cli/README.md b/cli/README.md
index 11066a7ba1..606a90c2ff 100644
--- a/cli/README.md
+++ b/cli/README.md
@@ -44,6 +44,7 @@ Choose a supported coding agent from your terminal and control its sessions remo
- `hapi resume [sessionId]` - List resumable sessions for this machine or resume one locally.
- `hapi ping-peer ` - Resume (if needed) and message another session. Prefer this or MCP `ping_peer` / `list_peers` over reinventing JWT+curl. Also `--message-file` / `--list`.
- `hapi inspect-peer ` - Read-only peer metadata + recent message text (no resume). Prefer this or MCP `inspect_peer` when a user cites `[title](/sessions/)` or Copy-reference `See session "…" (/sessions/) for context`. `/sessions/` is a hub path, not a local file. Optional `--limit`.
+- `hapi job set|update|clear|list` - Attach long-running outliving work to a session so the list UI shows progress while the agent is idle (`tiann/hapi#1404`). Prefer `"$HAPI_SESSION_ID"`. Heartbeat at least every ~10m; honest `--remaining` or `--done`/`--total` (omit counts if unknown — never invent a percent). See `docs/guide/session-jobs.md` and `hapi job --help`.
The picker lists agents alphabetically by command name. Use Up/Down and Enter
to choose; Esc or Ctrl-C cancels. It appears on every bare invocation, even
diff --git a/cli/src/claude/utils/systemPrompt.ts b/cli/src/claude/utils/systemPrompt.ts
index 3174ac6edd..03cfd1c7bc 100644
--- a/cli/src/claude/utils/systemPrompt.ts
+++ b/cli/src/claude/utils/systemPrompt.ts
@@ -2,6 +2,7 @@ import { trimIdent } from "@/utils/trimIdent";
import { buildSessionCitationSteerInstruction } from "@hapi/protocol/sessionCitation";
import { shouldIncludeCoAuthoredBy } from "./claudeSettings";
import { DISPLAY_IMAGE_PROMPT_CLAUDE, DISPLAY_MEDIA_PROMPT_CLAUDE, DISPLAY_VIDEO_PROMPT_CLAUDE } from "@/modules/common/displayImagePrompt";
+import { withSessionJobInstruction } from "@/modules/common/sessionJobInstruction";
import { withSessionSummaryInstruction } from "@/modules/common/sessionSummaryInstruction";
/**
@@ -42,5 +43,5 @@ export function getSystemPrompt(): string {
const base = includeCoAuthored
? BASE_SYSTEM_PROMPT + '\n\n' + CO_AUTHORED_CREDITS
: BASE_SYSTEM_PROMPT;
- return withSessionSummaryInstruction(base);
+ return withSessionSummaryInstruction(withSessionJobInstruction(base));
}
diff --git a/cli/src/codex/utils/systemPrompt.ts b/cli/src/codex/utils/systemPrompt.ts
index bd85efc36c..7ad815856e 100644
--- a/cli/src/codex/utils/systemPrompt.ts
+++ b/cli/src/codex/utils/systemPrompt.ts
@@ -8,6 +8,7 @@
import { trimIdent } from '@/utils/trimIdent';
import { buildSessionCitationSteerInstruction } from '@hapi/protocol/sessionCitation';
import { DISPLAY_IMAGE_PROMPT_CODEX, DISPLAY_MEDIA_PROMPT_CODEX, DISPLAY_VIDEO_PROMPT_CODEX } from '@/modules/common/displayImagePrompt';
+import { withSessionJobInstruction } from '@/modules/common/sessionJobInstruction';
import { withSessionSummaryInstruction } from '@/modules/common/sessionSummaryInstruction';
/**
@@ -36,7 +37,7 @@ export const TITLE_INSTRUCTION = trimIdent(`
* Session-summary contract is resolved at call time (hub toggle / env).
*/
export function getCodexSystemPrompt(env: NodeJS.ProcessEnv = process.env): string {
- return withSessionSummaryInstruction(TITLE_INSTRUCTION, env)
+ return withSessionSummaryInstruction(withSessionJobInstruction(TITLE_INSTRUCTION), env)
}
/** Alias kept for existing call sites / tests that expect a string constant name. */
diff --git a/cli/src/commands/job.ts b/cli/src/commands/job.ts
index cbd2fa55fc..1579621130 100644
--- a/cli/src/commands/job.ts
+++ b/cli/src/commands/job.ts
@@ -29,16 +29,33 @@ function showHelp(): void {
console.log(`
${chalk.bold('hapi job')} - Attach long-running work to a HAPI session (tiann/hapi#1404)
+${chalk.bold('When to use:')}
+ Work that outlives the agent (nohup / batch / long scripts / external daemons)
+ while the session may be idle. Not thinking progress or in-agent background tools.
+
+${chalk.bold('Agent contract:')}
+ 1. set before (or as) the process starts
+ 2. update / heartbeat at least every ~10 minutes while running
+ 3. prefer honest --remaining or --done/--total; omit counts if unknown
+ 4. never invent a fake percent
+ 5. clear or --status completed|failed when finished
+
${chalk.bold('Usage:')}
hapi job set --label [--remaining N] [--done N --total N] [--unit tracks] [--detail ...]
hapi job update [--remaining N] [--done N] [--total N] [--status running|completed|failed] [--detail ...]
hapi job clear
hapi job list
+${chalk.bold('Progress UI:')}
+ remaining → "N units left · 2h"
+ done + total → "P% · done/total · 2h"
+ label/detail only → "running · 2h" + indeterminate bar
+ elapsed always from startedAt (wall clock) — never an ETA / time-remaining field
+
${chalk.bold('Notes:')}
- Hub-persisted. Works while the agent is idle/offline — not thinking progress.
- Prefer honest remaining/done+total; never invent a fake percent.
+ Hub-persisted. Prefer "$HAPI_SESSION_ID" for this chat.
Job key: 1-128 chars, alnum / . _ -
+ Docs: docs/guide/session-jobs.md
${chalk.bold('Env:')}
HAPI_API_URL / CLI_API_TOKEN (or ~/.hapi/settings.json via \`hapi auth login\`)
@@ -166,6 +183,7 @@ function formatJobLine(job: {
unit?: string
detail?: string
heartbeatAt: number
+ startedAt: number
}): string {
const parts = [`${job.key}`, job.label, job.status]
if (job.remaining !== undefined) {
@@ -173,6 +191,20 @@ function formatJobLine(job: {
} else if (job.done !== undefined && job.total !== undefined) {
parts.push(`${job.done}/${job.total}${job.unit ? ` ${job.unit}` : ''}`)
}
+ const elapsedSec = Math.max(0, Math.round((Date.now() - job.startedAt) / 1000))
+ if (elapsedSec < 60) {
+ parts.push(`elapsed ${elapsedSec}s`)
+ } else if (elapsedSec < 3600) {
+ parts.push(`elapsed ${Math.floor(elapsedSec / 60)}m`)
+ } else if (elapsedSec < 86400) {
+ const h = Math.floor(elapsedSec / 3600)
+ const m = Math.floor((elapsedSec % 3600) / 60)
+ parts.push(m > 0 ? `elapsed ${h}h ${m}m` : `elapsed ${h}h`)
+ } else {
+ const d = Math.floor(elapsedSec / 86400)
+ const h = Math.floor((elapsedSec % 86400) / 3600)
+ parts.push(h > 0 ? `elapsed ${d}d ${h}h` : `elapsed ${d}d`)
+ }
if (job.detail) parts.push(job.detail)
const ageSec = Math.max(0, Math.round((Date.now() - job.heartbeatAt) / 1000))
parts.push(`heartbeat ${ageSec}s ago`)
diff --git a/cli/src/grok/utils/systemPrompt.ts b/cli/src/grok/utils/systemPrompt.ts
index 695bc9ea42..07af91a8fd 100644
--- a/cli/src/grok/utils/systemPrompt.ts
+++ b/cli/src/grok/utils/systemPrompt.ts
@@ -1,9 +1,10 @@
import { SKILL_LOOKUP_INSTRUCTION } from '@/modules/common/skillLookupInstruction'
+import { withSessionJobInstruction } from '@/modules/common/sessionJobInstruction'
import { withSessionSummaryInstruction } from '@/modules/common/sessionSummaryInstruction'
export const GROK_TITLE_INSTRUCTION =
`Use the tool "hapi_change_title" once after the initial request is clear to set a concise session title. Do not rename for routine progress or substeps.\n${SKILL_LOOKUP_INSTRUCTION}`
export function getGrokTitleInstruction(env: NodeJS.ProcessEnv = process.env): string {
- return withSessionSummaryInstruction(GROK_TITLE_INSTRUCTION, env)
+ return withSessionSummaryInstruction(withSessionJobInstruction(GROK_TITLE_INSTRUCTION), env)
}
diff --git a/cli/src/modules/common/sessionJobInstruction.test.ts b/cli/src/modules/common/sessionJobInstruction.test.ts
new file mode 100644
index 0000000000..2cdf00bedb
--- /dev/null
+++ b/cli/src/modules/common/sessionJobInstruction.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from 'vitest'
+import {
+ SESSION_JOB_INSTRUCTION,
+ withSessionJobInstruction
+} from './sessionJobInstruction'
+
+describe('sessionJobInstruction', () => {
+ it('mentions set, update, heartbeat, and no fake percent', () => {
+ expect(SESSION_JOB_INSTRUCTION).toContain('hapi job set')
+ expect(SESSION_JOB_INSTRUCTION).toContain('hapi job update')
+ expect(SESSION_JOB_INSTRUCTION).toContain('~10 minutes')
+ expect(SESSION_JOB_INSTRUCTION).toContain('Never invent a fake percent')
+ expect(SESSION_JOB_INSTRUCTION).toContain('HAPI_SESSION_ID')
+ })
+
+ it('appends after an existing prompt block', () => {
+ expect(withSessionJobInstruction('Base.')).toBe(`Base.\n\n${SESSION_JOB_INSTRUCTION}`)
+ expect(withSessionJobInstruction('')).toBe(SESSION_JOB_INSTRUCTION)
+ })
+})
diff --git a/cli/src/modules/common/sessionJobInstruction.ts b/cli/src/modules/common/sessionJobInstruction.ts
new file mode 100644
index 0000000000..c6ba00c3d2
--- /dev/null
+++ b/cli/src/modules/common/sessionJobInstruction.ts
@@ -0,0 +1,33 @@
+/**
+ * Always-on steer for session-attached long-running jobs (tiann/hapi#1404).
+ *
+ * Unlike the session-summary contract (opt-in), this is short and triggers only
+ * when the agent spawns outliving work — so it rides every supported flavor's
+ * system / developer instructions by default.
+ *
+ * Cursor ACP has no system-prompt seam today; Cursor agents rely on the estate
+ * skill `hapi-session-jobs` (and `hapi job --help`) instead.
+ */
+
+/** Canonical one-block contract. Keep short — every session's prompt budget. */
+export const SESSION_JOB_INSTRUCTION = [
+ 'Session-attached jobs (outliving work):',
+ 'When you start work that will keep running after this agent goes idle',
+ '(nohup, batch imports, long scripts, external daemons), attach it to this',
+ 'HAPI session so the session list can show progress while you are idle.',
+ 'Use: hapi job set "$HAPI_SESSION_ID" --label ',
+ '[--remaining N] [--done N --total N] [--unit ] [--detail ].',
+ 'Heartbeat with hapi job update at least every ~10 minutes (UI goes amber',
+ 'after ~15m without a heartbeat). Prefer honest remaining or done+total;',
+ 'omit counts when unknown (UI shows "running" + indeterminate bar).',
+ 'Never invent a fake percent. On finish: hapi job update … --status',
+ 'completed|failed, or hapi job clear. Full contract: hapi job --help.'
+].join(' ')
+
+/** Append instruction to an existing prompt block (blank line separator). */
+export function withSessionJobInstruction(base: string): string {
+ const trimmed = base.trimEnd()
+ return trimmed.length > 0
+ ? `${trimmed}\n\n${SESSION_JOB_INSTRUCTION}`
+ : SESSION_JOB_INSTRUCTION
+}
diff --git a/cli/src/opencode/utils/systemPrompt.test.ts b/cli/src/opencode/utils/systemPrompt.test.ts
index b22d3f9e2a..483c901075 100644
--- a/cli/src/opencode/utils/systemPrompt.test.ts
+++ b/cli/src/opencode/utils/systemPrompt.test.ts
@@ -3,7 +3,7 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { ensureOpencodeConfig } from './opencodeConfig'
-import { TITLE_INSTRUCTION } from './systemPrompt'
+import { TITLE_INSTRUCTION, getTitleInstruction } from './systemPrompt'
describe('OpenCode local HAPI instructions', () => {
let configDirectory: string | null = null
@@ -20,11 +20,13 @@ describe('OpenCode local HAPI instructions', () => {
const { instructionsPath } = ensureOpencodeConfig(
configDirectory,
{ command: 'hapi', args: ['mcp'] },
- TITLE_INSTRUCTION
+ getTitleInstruction({})
)
const instructions = await readFile(instructionsPath, 'utf8')
expect(instructions).toContain('$name')
expect(instructions).toContain('skill_lookup')
+ expect(instructions).toContain('hapi job set')
+ expect(instructions).toContain(TITLE_INSTRUCTION.trim())
})
})
diff --git a/cli/src/opencode/utils/systemPrompt.ts b/cli/src/opencode/utils/systemPrompt.ts
index b1838d33e7..9844625196 100644
--- a/cli/src/opencode/utils/systemPrompt.ts
+++ b/cli/src/opencode/utils/systemPrompt.ts
@@ -14,6 +14,7 @@ import {
DISPLAY_VIDEO_PROMPT_HAPI_MCP,
} from '@/modules/common/displayImagePrompt';
import { SKILL_LOOKUP_INSTRUCTION } from '@/modules/common/skillLookupInstruction';
+import { withSessionJobInstruction } from '@/modules/common/sessionJobInstruction';
import { withSessionSummaryInstruction } from '@/modules/common/sessionSummaryInstruction';
/**
@@ -30,7 +31,7 @@ export const TITLE_INSTRUCTION = trimIdent(`
`);
export function getTitleInstruction(env: NodeJS.ProcessEnv = process.env): string {
- return withSessionSummaryInstruction(TITLE_INSTRUCTION, env)
+ return withSessionSummaryInstruction(withSessionJobInstruction(TITLE_INSTRUCTION), env)
}
/**
@@ -50,7 +51,7 @@ export const OPENCODE_NATIVE_TOOL_INSTRUCTION = trimIdent(`
`);
export function getOpencodeNativeToolInstruction(env: NodeJS.ProcessEnv = process.env): string {
- return withSessionSummaryInstruction(OPENCODE_NATIVE_TOOL_INSTRUCTION, env)
+ return withSessionSummaryInstruction(withSessionJobInstruction(OPENCODE_NATIVE_TOOL_INSTRUCTION), env)
}
/**
diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts
index e4c4f3fa70..4952a99948 100644
--- a/docs/.vitepress/config.ts
+++ b/docs/.vitepress/config.ts
@@ -31,6 +31,7 @@ export default defineConfig({
text: 'Guide',
items: [
{ text: 'How it Works', link: '/guide/how-it-works' },
+ { text: 'Session-attached jobs', link: '/guide/session-jobs' },
{ text: 'Voice Assistant', link: '/guide/voice-assistant' },
{ text: 'Why HAPI', link: '/guide/why-hapi' },
{ text: 'FAQ', link: '/guide/faq' }
diff --git a/docs/guide/faq.md b/docs/guide/faq.md
index f68e6183c9..2d050d67cf 100644
--- a/docs/guide/faq.md
+++ b/docs/guide/faq.md
@@ -110,6 +110,10 @@ Yes. Open any session and use the chat interface to send messages directly to th
Some agents (especially Cursor) can resume after idle from harness signals such as background Shell `notify_on_output` or `/loop`, without you sending a new HAPI message. HAPI updates the session's thinking indicator when the agent resumes work or requests permission, so the list reflects that activity.
+### How do I show progress for a long batch that outlives the agent?
+
+Use session-attached jobs (`hapi job`). The agent (or a wrapper script) registers a job on the session, heartbeats while the process runs, and clears it when done. The session list shows remaining / fraction / or an indeterminate "running" meter even when the agent is idle. See [Session-attached jobs](./session-jobs.md).
+
### Can I access a terminal remotely?
Yes. Open a session in the web app and tap the Terminal tab for a remote shell.
diff --git a/docs/guide/session-jobs.md b/docs/guide/session-jobs.md
new file mode 100644
index 0000000000..339d9be7d4
--- /dev/null
+++ b/docs/guide/session-jobs.md
@@ -0,0 +1,92 @@
+# Session-attached jobs
+
+Hub-persisted progress for work that **outlives the agent** — batch imports, `nohup` scripts, long drains — so the session list still shows something truthful while the chat is idle (`active: false`).
+
+This is **not** in-agent thinking progress, todos, or `backgroundTaskCount`. Those die when the agent disconnects. Attached jobs live on the hub until you clear them.
+
+Upstream: [tiann/hapi#1404](https://github.com/tiann/hapi/issues/1404).
+
+## When to attach
+
+Attach a job **before** (or immediately when) you start process-shaped work that will keep running after the agent goes idle:
+
+| Attach | Do not attach |
+|--------|----------------|
+| `nohup` / `setsid` / systemd oneshot that runs for hours–days | A tool call that finishes in this turn |
+| Beets / rclone / compile / migrate / download batches | Normal coding edits and tests |
+| External daemon you own for this session's goal | Claude/Codex Ctrl+B-style background tools |
+
+If the operator would reopen the chat only to ask "how's it doing?", it belongs here.
+
+## Agent contract (specification)
+
+HAPI does **not** write your batch scripts. You (the agent) create the process **and** feed the meter.
+
+1. **Register** with a stable `job-key` (1–128 chars: alnum / `.` `_` `-`).
+2. **Heartbeat** at least every ~10 minutes while running (UI amber after ~15 minutes quiet).
+3. **Report progress honestly** — see tiers below. Never invent a bare percent.
+4. **Finish cleanly** — `--status completed|failed` or `hapi job clear`.
+
+Session id: prefer `"$HAPI_SESSION_ID"` (exported into every HAPI-wrapped agent). Prefix match also works.
+
+```bash
+hapi job set "$HAPI_SESSION_ID" beets \
+ --label 'beets import' \
+ --remaining 150 --done 1637 --total 1787 --unit units \
+ --detail 'album: Some Artist - Some Album'
+
+hapi job update "$HAPI_SESSION_ID" beets --remaining 149 --done 1638 --detail '…'
+
+hapi job update "$HAPI_SESSION_ID" beets --status completed
+# or
+hapi job clear "$HAPI_SESSION_ID" beets
+```
+
+Same auth as `hapi ping-peer` (`HAPI_API_URL` / `CLI_API_TOKEN` or `hapi auth login`).
+
+## Progress honesty (tiers)
+
+| What you know | What to send | What the list shows |
+|---------------|--------------|---------------------|
+| Countable leftover | `--remaining N` (+ optional `--unit`) | `150 units left · 2d 4h` |
+| Countable fraction | `--done N --total M` | `91% · 1637/1787 units · 2d 4h` |
+| Stage only / unknown size | `--label` + `--detail` + heartbeats | `running · 2d 4h` + indeterminate bar |
+
+**Elapsed** is always derived from hub `startedAt` (wall clock since register). It is **not** an ETA and there is no time-remaining field - operators get "how long has this been going" plus whatever honest count/detail you report, without a fake completion estimate.
+
+Rules:
+
+- Prefer **remaining** when the operator cares about "how much left".
+- Prefer **done+total** when both ends of a fraction exist (UI may derive %).
+- If you only know a stage name, put it in `--detail` and keep heartbeating — do **not** fake `total=100`.
+- There is **no** `--percent` flag and **no** ETA / time-remaining field. Inventing either would train agents to lie.
+
+## Heartbeat recipe
+
+Wrap the long process so something calls `hapi job update` on a timer (or on each unit completed). Minimum viable indeterminate job:
+
+```bash
+hapi job set "$HAPI_SESSION_ID" rsync-backup --label 'rsync backup' --detail 'phase: copy'
+# in a loop / cron / companion script:
+hapi job update "$HAPI_SESSION_ID" rsync-backup --detail "phase: copy · $(date -u +%H:%M)Z"
+```
+
+When the process exits, mark completed/failed or clear. A stuck green/amber chip with a dead PID is worse than no chip.
+
+## CLI reference
+
+```bash
+hapi job set --label [options]
+hapi job update [options]
+hapi job clear
+hapi job list
+hapi job --help
+```
+
+Primary running job is enriched onto `GET /api/sessions` as `attachedJob` and pushed on `session-updated` SSE patches.
+
+## Related
+
+- [Supported Agents](./agents.md) — flavors and resume
+- [How it Works](./how-it-works.md) — CLI ↔ hub ↔ web
+- CLI: `hapi job --help`, `cli/README.md`
diff --git a/web/src/components/SessionRowSummary.tsx b/web/src/components/SessionRowSummary.tsx
index 4abc7fa584..625f7836c4 100644
--- a/web/src/components/SessionRowSummary.tsx
+++ b/web/src/components/SessionRowSummary.tsx
@@ -1,4 +1,4 @@
-import { useMemo } from 'react'
+import { useEffect, useMemo, useState } from 'react'
import type { SessionSummary } from '@/types/api'
import { AgentFlavorIcon } from '@/components/AgentFlavorIcon'
import { ScheduleIcon } from '@/components/icons'
@@ -17,7 +17,6 @@ import {
formatAttachedJobProgress,
isAttachedJobStale
} from '@/lib/attachedJob'
-
function LoaderIcon(props: { className?: string }) {
return (