refactor(jcode): fold three reverse-scan copies into one, reuse the shared hook POST

The jcode journal reader, the Claude transcript reader and the Command Code
transcript reader each carried their own copy of the same reverse chunked line
scan; they now share one tested helper. jcode's managed hook script drops its
hand-rolled curl for buildPosixAgentHookPostCommand, which also gains it the
raw-JSON transport and the --noproxy guard the bespoke copy was missing.

Co-authored-by: czzczz <chanzrz_zbf@foxmail.com>
This commit is contained in:
Neil
2026-10-03 01:49:40 -07:00
committed by Neil
co-authored by czzczz
parent 7fb201f60f
commit 0a45ad5ec4
17 changed files with 335 additions and 357 deletions
@@ -211,6 +211,22 @@ describe('shared agent-hook-listener: jcode', () => {
expect(event?.providerSession).toEqual({ key: 'session_id', id: 'session_jc_5' })
})
it('reads the lifecycle point from jcode\u2019s own `event` key', () => {
// Why this matters: the managed script posts JCODE_HOOK_PAYLOAD verbatim through
// the shared hook transport, so nothing re-states the event as a form field —
// jcode names it `event`, and the listener has to accept that.
const event = normalizeHookPayload(
state,
'jcode',
{
paneKey: PANE_KEY,
payload: { event: 'turn_start', session_id: 'session_jc_7', model: 'claude-haiku-4-5' }
},
'production'
)
expect(event?.payload).toMatchObject({ agentType: 'jcode', state: 'working' })
})
it('does not count a direct jcode prompt without journal evidence as explicit', () => {
// Why: regression — a prompt field on a hook event has no journal backing, so
// it must not set hasExplicitPrompt.
+5 -1
View File
@@ -60,7 +60,11 @@ export function normalizeHookPayload(
const eventName =
readFirstString(record, ['hook_event_name', 'hookEventName', 'hook_type', 'hookType']) ??
hookPayloadRecord.hook_event_name ??
hookPayloadRecord.hookEventName
hookPayloadRecord.hookEventName ??
// Why jcode only: its payload names the lifecycle point `event`, and it is posted
// verbatim through the shared transport rather than re-stated as a form field.
// Scoped so another provider's unrelated `event` key cannot become an event name.
(source === 'jcode' ? hookPayloadRecord.event : undefined)
// Codex child hooks expose the child's session_id on the parent's pane.
const providerSession =
source === 'codex' && readString(hookPayloadRecord, 'agent_id')
@@ -1,10 +1,9 @@
import { createHash } from 'node:crypto'
import { closeSync, openSync, readSync, statSync } from 'node:fs'
import { parseAgentHookJson } from './request-body'
import { scanFileRegionsBackward } from './reverse-file-region-scan'
import { extractAssistantContentText } from './transcript-entry-text'
import {
EMPTY_TRANSCRIPT_REGION,
readLastTextFromTranscriptOnce,
TRANSCRIPT_CHUNK_BYTES,
TRANSCRIPT_MAX_SCAN_BYTES
@@ -59,91 +58,24 @@ export function readLastCommandCodeUserPromptEntryFromTranscript(
if (typeof transcriptPath !== 'string' || transcriptPath.length === 0) {
return undefined
}
try {
const stats = statSync(transcriptPath)
const size = stats.size
if (size <= 0) {
return undefined
}
const fd = openSync(transcriptPath, 'r')
try {
// Why scan backward: the answer is the LAST user line, so walking up from
// EOF returns on the first hit instead of parsing every line of a
// multi-megabyte transcript on every hook event.
// Why a chunk list: carry holds a partial line, and re-concatenating it per
// block made one oversized line (a big tool result) cost O(line^2).
let carryChunks: Buffer[] = []
let bytesRead = 0
let scanEnd = size
while (scanEnd > 0 && bytesRead < TRANSCRIPT_MAX_SCAN_BYTES) {
const chunkSize = Math.min(
scanEnd,
TRANSCRIPT_CHUNK_BYTES,
TRANSCRIPT_MAX_SCAN_BYTES - bytesRead
)
const position = scanEnd - chunkSize
const buffer = Buffer.alloc(chunkSize)
let filled = 0
while (filled < chunkSize) {
const n = readSync(fd, buffer, filled, chunkSize - filled, position + filled)
if (n === 0) {
break
return scanFileRegionsBackward(
transcriptPath,
{ chunkBytes: TRANSCRIPT_CHUNK_BYTES, maxScanBytes: TRANSCRIPT_MAX_SCAN_BYTES },
(region, regionPosition) => {
const found = findLastCommandCodePromptInRegion(region)
return found
? {
text: found.prompt,
interactionKey: [
'command-code-transcript',
hashInteractionKeyPart(transcriptPath),
String(regionPosition + found.byteOffset),
hashInteractionKeyPart(found.prompt)
].join('-')
}
filled += n
}
// Why bail on a short read: the file shrank under us, so the bytes above
// this block no longer line up and any stitched offset would be wrong.
if (filled < chunkSize) {
break
}
bytesRead += filled
scanEnd = position
// Why search only the new block: carry is always the run before a newline,
// so it holds none of its own.
const firstNewline = buffer.indexOf(0x0a)
// Why only at a true file start: a scan that stops on the size cap must
// discard its leading partial line, exactly as the capped read did.
const atStart = position === 0
let completeRegion: Buffer
let regionPosition: number
if (atStart) {
completeRegion =
carryChunks.length === 0 ? buffer : Buffer.concat([buffer, ...carryChunks])
regionPosition = position
carryChunks = []
} else if (firstNewline === -1) {
completeRegion = EMPTY_TRANSCRIPT_REGION
regionPosition = position
carryChunks.unshift(buffer)
} else {
const afterNewline = buffer.subarray(firstNewline + 1)
completeRegion =
carryChunks.length === 0 ? afterNewline : Buffer.concat([afterNewline, ...carryChunks])
regionPosition = position + firstNewline + 1
carryChunks = [buffer.subarray(0, firstNewline)]
}
if (completeRegion.length > 0) {
const found = findLastCommandCodePromptInRegion(completeRegion)
if (found) {
return {
text: found.prompt,
interactionKey: [
'command-code-transcript',
hashInteractionKeyPart(transcriptPath),
String(regionPosition + found.byteOffset),
hashInteractionKeyPart(found.prompt)
].join('-')
}
}
}
}
return undefined
} finally {
closeSync(fd)
: undefined
}
} catch {
return undefined
}
)
}
export function extractCommandCodeAssistantTextFromLine(line: string): string | undefined {
@@ -1,5 +1,6 @@
import {
normalizeAgentStatusPayload,
type AgentStatusState,
type ParsedAgentStatusPayload
} from '../../agent-status-types'
import { clearPaneTurnCacheState, type HookListenerState } from '../listener-state'
@@ -8,9 +9,14 @@ import { extractToolFields, isNewTurnEvent } from '../provider-event-routing'
import { readString } from '../tool-input-preview'
import { isJcodeUserInputTool } from './jcode-tool-fields'
/** Text jcode reports for a failed turn or tool, preferred over a stale reply. */
function readJcodeErrorText(hookPayload: Record<string, unknown>): string | undefined {
return hookPayload.status === 'error' ? readString(hookPayload, 'error') : undefined
// jcode's six lifecycle points, mapped the way docs/reference/jcode-hook-events.md
// records them. `session_start` is absent on purpose: it returns early below.
const JCODE_EVENT_STATES: Record<string, AgentStatusState> = {
turn_start: 'working',
pre_tool: 'working',
post_tool: 'working',
turn_end: 'done',
session_end: 'done'
}
export function normalizeJcodeEvent(
@@ -27,34 +33,29 @@ export function normalizeJcodeEvent(
return null
}
const toolName = readString(hookPayload, 'tool_name')
// Why: only the pre_tool gate can report a pending question — post_tool fires
// after the human already answered it.
const isPendingUserInput = eventName === 'pre_tool' && isJcodeUserInputTool(toolName)
const stateName = isPendingUserInput
? 'waiting'
: eventName === 'turn_start' || eventName === 'pre_tool' || eventName === 'post_tool'
? 'working'
: eventName === 'turn_end' || eventName === 'session_end'
? 'done'
: null
// Why the gate only: post_tool for the same tool fires after the human already
// answered, so it must not re-open the question.
const stateName =
eventName === 'pre_tool' && isJcodeUserInputTool(readString(hookPayload, 'tool_name'))
? 'waiting'
: JCODE_EVENT_STATES[String(eventName)]
if (!stateName) {
return null
}
const resetOnNewTurn = isNewTurnEvent('jcode', eventName)
const snapshot = resolveToolState(
state,
paneKey,
extractToolFields('jcode', eventName, hookPayload),
{ resetOnNewTurn: isNewTurnEvent('jcode', eventName) }
{ resetOnNewTurn }
)
// Why the error text first: a failed turn's own message beats the reply it never replaced.
const errorText = hookPayload.status === 'error' ? readString(hookPayload, 'error') : undefined
return normalizeAgentStatusPayload({
state: stateName,
prompt: resolvePrompt(state, paneKey, promptText, {
resetOnNewTurn: isNewTurnEvent('jcode', eventName)
}),
prompt: resolvePrompt(state, paneKey, promptText, { resetOnNewTurn }),
agentType: 'jcode',
// Why: jcode stamps the live model on session_start/turn_start/turn_end, so
// the row keeps naming the right model after an in-session `/model` switch.
@@ -62,6 +63,6 @@ export function normalizeJcodeEvent(
toolName: snapshot.toolName,
toolInput: snapshot.toolInput,
interactivePrompt: snapshot.interactivePrompt,
lastAssistantMessage: readJcodeErrorText(hookPayload) ?? snapshot.lastAssistantMessage
lastAssistantMessage: errorText ?? snapshot.lastAssistantMessage
})
}
@@ -7,16 +7,15 @@ import {
toolUpdate
} from '../tool-input-preview'
// Why: jcode has no per-tool approval prompt — its safety model denies or asks
// the model to reflect, both inside the tool. The one tool a *human* answers is
// ambient mode's `request_permission` (crates/jcode-app-core/src/tool/ambient.rs),
// resolved out of band with `jcode permissions`. Matching by exact name keeps a
// future rename visible instead of silently widening to unrelated tools; the
// aliases are the names jcode has shipped for the same surface.
const JCODE_USER_INPUT_TOOLS = new Set(['request_permission', 'ask_user', 'ask_question'])
/** True for the one jcode tool a *human* answers.
*
* jcode has no per-tool approval prompt — its safety model denies or asks the model
* to reflect, both inside the tool. `request_permission`
* (crates/jcode-app-core/src/tool/ambient.rs) is the only surface that waits on a
* person, resolved out of band with `jcode permissions`. Matched by exact name so a
* rename fails loudly here rather than silently widening to unrelated tools. */
export function isJcodeUserInputTool(toolName: string | undefined): boolean {
return toolName !== undefined && JCODE_USER_INPUT_TOOLS.has(toolName)
return toolName === 'request_permission'
}
/** jcode's `tool_input` field is the tool's argument JSON as a string. */
@@ -0,0 +1,91 @@
import { afterEach, describe, expect, it } from 'vitest'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { scanFileRegionsBackward } from './reverse-file-region-scan'
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) {
rmSync(root, { recursive: true, force: true })
}
})
function writeFixture(contents: string): string {
const root = mkdtempSync(join(tmpdir(), 'orca-reverse-scan-'))
roots.push(root)
const file = join(root, 'transcript.jsonl')
writeFileSync(file, contents)
return file
}
/** Every whole line the scan handed out, in the order the caller saw them. */
function collectLines(file: string, chunkBytes: number, maxScanBytes = 1024 * 1024): string[] {
const seen: string[] = []
scanFileRegionsBackward(file, { chunkBytes, maxScanBytes }, (region) => {
seen.push(...region.toString('utf8').split('\n').filter(Boolean))
return undefined
})
return seen
}
describe('scanFileRegionsBackward', () => {
it('yields every whole line exactly once, whatever the chunk size', () => {
const lines = Array.from({ length: 40 }, (_, i) => `line-${i}`)
const file = writeFixture(`${lines.join('\n')}\n`)
for (const chunkBytes of [8, 16, 64, 4096]) {
// Why sorted: the scan walks backwards, so the caller sees later lines first.
expect([...collectLines(file, chunkBytes)].sort()).toEqual([...lines].sort())
}
})
it('stops at the first defined result without reading the rest of the file', () => {
const file = writeFixture(`${Array.from({ length: 200 }, (_, i) => `line-${i}`).join('\n')}\n`)
let regions = 0
const found = scanFileRegionsBackward(
file,
{ chunkBytes: 32, maxScanBytes: 1024 * 1024 },
() => {
regions += 1
return 'first'
}
)
expect(found).toBe('first')
expect(regions).toBe(1)
})
it('reports the byte offset each region starts at', () => {
const file = writeFixture('alpha\nbravo\ncharlie\n')
const offsets = scanFileRegionsBackward(
file,
{ chunkBytes: 4096, maxScanBytes: 4096 },
(region, regionPosition) => ({ text: region.toString('utf8'), regionPosition })
)
expect(offsets).toEqual({ text: 'alpha\nbravo\ncharlie\n', regionPosition: 0 })
})
it('drops the leading partial line when it stops on the byte budget', () => {
// Why: a scan capped mid-file has not seen the start of its topmost line, so
// emitting it would hand the caller a truncated record.
const file = writeFixture(`${'x'.repeat(500)}\nkeep-me\n`)
expect(collectLines(file, 64, 128)).toEqual(['keep-me'])
})
it('reads a file with no trailing newline', () => {
expect(collectLines(writeFixture('alpha\nbravo'), 4)).toContain('bravo')
})
it('treats an empty, missing, or unreadable file as nothing found', () => {
expect(collectLines(writeFixture(''), 64)).toEqual([])
expect(
scanFileRegionsBackward(
'/nonexistent/transcript.jsonl',
{
chunkBytes: 64,
maxScanBytes: 64
},
() => 'x'
)
).toBeUndefined()
})
})
@@ -0,0 +1,88 @@
import { closeSync, openSync, readSync, statSync } from 'node:fs'
/**
* Walk a file backwards in chunks, handing each caller a region of whole lines.
*
* Why backward: every caller wants the LAST entry that matches, so walking up from
* EOF returns on the first hit instead of parsing every line of a multi-megabyte
* transcript on every hook event.
*
* Why a chunk list rather than a growing buffer: `carry` holds the partial line that
* straddles a block boundary, and re-concatenating it per block made one oversized
* line (a big tool result or pasted prompt) cost O(line²).
*
* `visit` receives only whole lines, and the byte offset that region starts at.
* The scan stops at the first defined result, at the byte budget, or at the file
* start — whichever comes first. Any error reads as "nothing found".
*/
export function scanFileRegionsBackward<T>(
filePath: string,
limits: { chunkBytes: number; maxScanBytes: number },
visit: (region: Buffer, regionPosition: number) => T | undefined
): T | undefined {
try {
const size = statSync(filePath).size
if (size <= 0) {
return undefined
}
const fd = openSync(filePath, 'r')
try {
let carryChunks: Buffer[] = []
let bytesRead = 0
let scanEnd = size
while (scanEnd > 0 && bytesRead < limits.maxScanBytes) {
const chunkSize = Math.min(scanEnd, limits.chunkBytes, limits.maxScanBytes - bytesRead)
const position = scanEnd - chunkSize
const buffer = Buffer.alloc(chunkSize)
let filled = 0
while (filled < chunkSize) {
const read = readSync(fd, buffer, filled, chunkSize - filled, position + filled)
if (read === 0) {
break
}
filled += read
}
// Why bail on a short read: the file shrank under us, so the bytes above this
// block no longer line up with what the earlier ones assumed.
if (filled < chunkSize) {
return undefined
}
bytesRead += filled
scanEnd = position
// Why search only the new block: carry is always the run before a newline, so
// it holds none of its own.
const firstNewline = buffer.indexOf(0x0a)
let region: Buffer
let regionPosition = position
if (position === 0) {
// Only at a true file start is the leading partial line a whole line. A scan
// that stops on the byte cap must discard it, as a capped read would.
region = carryChunks.length === 0 ? buffer : Buffer.concat([buffer, ...carryChunks])
carryChunks = []
} else if (firstNewline === -1) {
region = EMPTY_REGION
carryChunks.unshift(buffer)
} else {
const afterNewline = buffer.subarray(firstNewline + 1)
region =
carryChunks.length === 0 ? afterNewline : Buffer.concat([afterNewline, ...carryChunks])
regionPosition = position + firstNewline + 1
carryChunks = [buffer.subarray(0, firstNewline)]
}
if (region.length > 0) {
const found = visit(region, regionPosition)
if (found !== undefined) {
return found
}
}
}
return undefined
} finally {
closeSync(fd)
}
} catch {
return undefined
}
}
const EMPTY_REGION = Buffer.alloc(0)
@@ -1,6 +1,5 @@
import { closeSync, openSync, readSync, statSync } from 'node:fs'
import { extractAssistantTextFromLine } from './transcript-entry-text'
import { scanFileRegionsBackward } from './reverse-file-region-scan'
export const TRANSCRIPT_CHUNK_BYTES = 64 * 1024
export const TRANSCRIPT_MAX_SCAN_BYTES = 4 * 1024 * 1024
@@ -13,73 +12,11 @@ export function readLastTextFromTranscriptOnce(
transcriptPath: string,
extractLineText: (line: string) => string | undefined
): string | undefined {
try {
const stats = statSync(transcriptPath)
const size = stats.size
if (size <= 0) {
return undefined
}
const fd = openSync(transcriptPath, 'r')
try {
// Why a chunk list: carry holds a partial line, and re-joining it per block
// made one oversized line (a big tool result or pasted prompt) cost O(line^2).
let carryChunks: Buffer[] = []
let bytesRead = 0
let scanEnd = size
while (scanEnd > 0 && bytesRead < TRANSCRIPT_MAX_SCAN_BYTES) {
const chunkSize = Math.min(scanEnd, TRANSCRIPT_CHUNK_BYTES)
const position = scanEnd - chunkSize
const buffer = Buffer.alloc(chunkSize)
let filled = 0
while (filled < chunkSize) {
const n = readSync(fd, buffer, filled, chunkSize - filled, position + filled)
if (n === 0) {
break
}
filled += n
}
// Why bail on a short read: the file shrank under us, so the bytes above
// this block no longer line up with what the earlier ones assumed.
if (filled < chunkSize) {
break
}
bytesRead += filled
scanEnd = position
// Why search only the new block: carry is always the run before a newline,
// so it holds none of its own.
const firstNewline = buffer.indexOf(0x0a)
const atStart = position === 0
let completeRegion: Buffer
if (atStart) {
completeRegion =
carryChunks.length === 0 ? buffer : Buffer.concat([buffer, ...carryChunks])
carryChunks = []
} else if (firstNewline === -1) {
completeRegion = EMPTY_TRANSCRIPT_REGION
carryChunks.unshift(buffer)
} else {
const afterNewline = buffer.subarray(firstNewline + 1)
completeRegion =
carryChunks.length === 0 ? afterNewline : Buffer.concat([afterNewline, ...carryChunks])
carryChunks = [buffer.subarray(0, firstNewline)]
}
if (completeRegion.length > 0) {
const extracted = findLastExtractedTranscriptLineText(
completeRegion.toString('utf8'),
extractLineText
)
if (extracted !== undefined) {
return extracted
}
}
}
return undefined
} finally {
closeSync(fd)
}
} catch {
return undefined
}
return scanFileRegionsBackward(
transcriptPath,
{ chunkBytes: TRANSCRIPT_CHUNK_BYTES, maxScanBytes: TRANSCRIPT_MAX_SCAN_BYTES },
(region) => findLastExtractedTranscriptLineText(region.toString('utf8'), extractLineText)
)
}
export function findLastExtractedTranscriptLineText(
+11 -64
View File
@@ -4,15 +4,15 @@
// consolidated `session_*.json` document. Bounded like the Grok/Command Code
// transcript readers so hook events stay cheap on multi-megabyte sessions.
import { createHash } from 'node:crypto'
import { closeSync, openSync, readFileSync, readSync, statSync } from 'node:fs'
import { readFileSync, statSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { scanFileRegionsBackward } from './agent-hook-listener/reverse-file-region-scan'
const JCODE_SESSION_ID_MAX_LENGTH = 512
const JCODE_SESSION_SCAN_BYTES = 4 * 1024 * 1024
const JCODE_JOURNAL_CHUNK_BYTES = 64 * 1024
const JCODE_JSON_DOC_MAX_PARSE_BYTES = 8 * 1024 * 1024
const EMPTY_REGION = Buffer.alloc(0)
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
@@ -136,68 +136,15 @@ function readLastUserMessageFromJournal(
journalPath: string,
sessionId: string
): JcodeUserPromptEvidence | null {
let stats: ReturnType<typeof statSync>
try {
stats = statSync(journalPath)
} catch {
return null
}
if (stats.size <= 0) {
return null
}
try {
const fd = openSync(journalPath, 'r')
try {
// Why backward scan: the last user message is near EOF; the first hit
// returns instead of parsing a multi-megabyte log from the top.
let carryChunks: Buffer[] = []
let bytesRead = 0
let scanEnd = stats.size
while (scanEnd > 0 && bytesRead < JCODE_SESSION_SCAN_BYTES) {
const chunkSize = Math.min(
scanEnd,
JCODE_JOURNAL_CHUNK_BYTES,
JCODE_SESSION_SCAN_BYTES - bytesRead
)
const position = scanEnd - chunkSize
const buffer = Buffer.alloc(chunkSize)
const filled = readSync(fd, buffer, 0, chunkSize, position)
if (filled < chunkSize) {
break
}
bytesRead += filled
scanEnd = position
const firstNewline = buffer.indexOf(0x0a)
const atStart = position === 0
let completeRegion: Buffer
if (atStart) {
completeRegion =
carryChunks.length === 0 ? buffer : Buffer.concat([buffer, ...carryChunks])
carryChunks = []
} else if (firstNewline === -1) {
completeRegion = EMPTY_REGION
carryChunks.unshift(buffer)
} else {
const afterNewline = buffer.subarray(firstNewline + 1)
completeRegion =
carryChunks.length === 0 ? afterNewline : Buffer.concat([afterNewline, ...carryChunks])
carryChunks = [buffer.subarray(0, firstNewline)]
}
if (completeRegion.length > 0) {
const lines = completeRegion.toString('utf8').split('\n')
const found = readLastUserMessageFromJournalLines(lines, sessionId)
if (found) {
return found
}
}
}
return null
} finally {
closeSync(fd)
}
} catch {
return null
}
return (
scanFileRegionsBackward(
journalPath,
{ chunkBytes: JCODE_JOURNAL_CHUNK_BYTES, maxScanBytes: JCODE_SESSION_SCAN_BYTES },
(region) =>
readLastUserMessageFromJournalLines(region.toString('utf8').split('\n'), sessionId) ??
undefined
) ?? null
)
}
function readLastUserMessageFromJson(
+6 -6
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { isJcodeIdentityTerminalTitle, stripJcodeTitleMetrics } from './jcode-terminal-title'
import { isJcodeIdentityTerminalTitle, stripJcodeTitleStatus } from './jcode-terminal-title'
// Every title below was captured from a real jcode 0.87.1 TUI session; see
// docs/reference/jcode-hook-events.md.
@@ -19,11 +19,11 @@ const CAPTURED_TITLES = [
describe('jcode terminal titles', () => {
it('strips the live diff and duration segments', () => {
expect(stripJcodeTitleMetrics('jcode Puppy · +3 -0 · last ~23s')).toBe('jcode Puppy')
expect(stripJcodeTitleMetrics('🌐 jcode Puppy · +3 -0 · last ~23s')).toBe('jcode Puppy')
expect(stripJcodeTitleMetrics('jcode Snake · work ~6s')).toBe('jcode Snake')
expect(stripJcodeTitleMetrics('jcode Snake · last ~1m02s')).toBe('jcode Snake')
expect(stripJcodeTitleMetrics('jcode Snake · work ~2h05m')).toBe('jcode Snake')
expect(stripJcodeTitleStatus('jcode Puppy · +3 -0 · last ~23s')).toBe('jcode Puppy')
expect(stripJcodeTitleStatus('🌐 jcode Puppy · +3 -0 · last ~23s')).toBe('jcode Puppy')
expect(stripJcodeTitleStatus('jcode Snake · work ~6s')).toBe('jcode Snake')
expect(stripJcodeTitleStatus('jcode Snake · last ~1m02s')).toBe('jcode Snake')
expect(stripJcodeTitleStatus('jcode Snake · work ~2h05m')).toBe('jcode Snake')
})
it.each(CAPTURED_TITLES)('treats %j as identity, not a conversation name', (title) => {
+12 -18
View File
@@ -1,27 +1,21 @@
// jcode paints `<emoji> jcode <session-name>[ · +N -M][ · work|last ~<dur>]` about
// once a second (crates/jcode-tui/src/tui/app/terminal_title.rs). Everything after
// the first ` · ` is live metrics, and the head is jcode's identity plus the
// codename it generates per session ("Puppy", "Tigress") — a label, never a
// jcode paints `<emoji> jcode <session-name>[ · +N -M][ · work|last ~<dur>]` about once
// a second (crates/jcode-tui/src/tui/app/terminal_title.rs). The tail is live status,
// the emoji is picked per session and swapped mid-turn, and the head is jcode's own
// name plus the codename it generates ("Puppy", "Tigress") — a label, never a
// conversation name. Captured titles are in docs/reference/jcode-hook-events.md.
const JCODE_TITLE_METRICS_RE = /\s+·\s+(?:\+\d+\s+-\d+|(?:work|last)\s+~\S+)(?=\s+·\s+|$)/gu
// jcode picks a per-session emoji (🐍 Snake, 🐕 Puppy) and swaps it for a status one
// mid-turn, so it identifies nothing stable. It is not in the shared decoration
// stripper because that list is a fixed set of agent status glyphs, not open-ended.
const JCODE_TITLE_LEADING_EMOJI_RE = /^(?:\p{Extended_Pictographic}|\uFE0F|\u200D)+\s*/u
const JCODE_TITLE_STATUS_RE =
/^[\p{Extended_Pictographic}\u{FE0F}\u{200D}]+\s*|\s+·\s+(?:\+\d+\s+-\d+|(?:work|last)\s+~\S+)(?=\s+·\s+|$)/gu
/** The jcode title with its leading emoji and live diff/duration segments removed. */
export function stripJcodeTitleMetrics(title: string): string {
return title.replace(JCODE_TITLE_LEADING_EMOJI_RE, '').replace(JCODE_TITLE_METRICS_RE, '').trim()
/** The jcode title with its per-session emoji and live diff/duration segments removed. */
export function stripJcodeTitleStatus(title: string): string {
return title.replace(JCODE_TITLE_STATUS_RE, '').trim()
}
// `jcode` alone, `jcode Puppy`, and `jcode/creek Puppy` (the self-dev variant) are
// all identity; anything the user could recognise as their own work has more to it.
// `jcode`, `jcode Puppy`, and `jcode/creek Puppy` (the self-dev variant) are all
// identity; anything the user could recognise as their own work has more to it.
const JCODE_IDENTITY_TITLE_RE = /^jcode(?:\/[^\s·]+)?(?:\s+[^\s·]+)?$/iu
/** True when a jcode title says only which jcode session this is, not what it is doing. */
export function isJcodeIdentityTerminalTitle(title: string | null | undefined): boolean {
if (!title) {
return false
}
return JCODE_IDENTITY_TITLE_RE.test(stripJcodeTitleMetrics(title))
return Boolean(title) && JCODE_IDENTITY_TITLE_RE.test(stripJcodeTitleStatus(title ?? ''))
}