2026-02-19 14:39:37 -06:00
import fs from "node:fs/promises" ;
2026-02-19 14:01:58 -06:00
import path from "node:path" ;
2026-02-19 14:39:37 -06:00
import { fileURLToPath } from "node:url" ;
2026-03-14 22:00:12 -05:00
import { inferOpenAiCompatibleBiller , type AdapterExecutionContext , type AdapterExecutionResult } from "@paperclipai/adapter-utils" ;
2026-02-18 13:53:03 -06:00
import {
asString ,
asNumber ,
parseObject ,
buildPaperclipEnv ,
2026-03-28 15:42:14 -05:00
buildInvocationEnvForLogs ,
2026-02-18 13:53:03 -06:00
ensureAbsoluteDirectory ,
ensureCommandResolvable ,
2026-03-12 15:44:44 -05:00
ensurePaperclipSkillSymlink ,
2026-02-18 13:53:03 -06:00
ensurePathInEnv ,
2026-03-15 07:05:01 -05:00
readPaperclipRuntimeSkillEntries ,
2026-03-28 15:42:14 -05:00
resolveCommandForLogs ,
2026-03-15 07:05:01 -05:00
resolvePaperclipDesiredSkillNames ,
2026-02-18 13:53:03 -06:00
renderTemplate ,
2026-03-28 09:55:41 -05:00
renderPaperclipWakePrompt ,
stringifyPaperclipWakePayload ,
[codex] Add run liveness continuations (#4083)
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies.
> - Heartbeat runs are the control-plane record of each agent execution
window.
> - Long-running local agents can exhaust context or stop while still
holding useful next-step state.
> - Operators need that stop reason, next action, and continuation path
to be durable and visible.
> - This pull request adds run liveness metadata, continuation
summaries, and UI surfaces for issue run ledgers.
> - The benefit is that interrupted or long-running work can resume with
clearer context instead of losing the agent's last useful handoff.
## What Changed
- Added heartbeat-run liveness fields, continuation attempt tracking,
and an idempotent `0058` migration.
- Added server services and tests for run liveness, continuation
summaries, stop metadata, and activity backfill.
- Wired local and HTTP adapters to surface continuation/liveness context
through shared adapter utilities.
- Added shared constants, validators, and heartbeat types for liveness
continuation state.
- Added issue-detail UI surfaces for continuation handoffs and the run
ledger, with component tests.
- Updated agent runtime docs, heartbeat protocol docs, prompt guidance,
onboarding assets, and skills instructions to explain continuation
behavior.
- Addressed Greptile feedback by scoping document evidence by run,
excluding system continuation-summary documents from liveness evidence,
importing shared liveness types, surfacing hidden ledger run counts,
documenting bounded retry behavior, and moving run-ledger liveness
backfill off the request path.
## Verification
- `pnpm exec vitest run packages/adapter-utils/src/server-utils.test.ts
server/src/__tests__/run-continuations.test.ts
server/src/__tests__/run-liveness.test.ts
server/src/__tests__/activity-service.test.ts
server/src/__tests__/documents-service.test.ts
server/src/__tests__/issue-continuation-summary.test.ts
server/src/services/heartbeat-stop-metadata.test.ts
ui/src/components/IssueRunLedger.test.tsx
ui/src/components/IssueContinuationHandoff.test.tsx
ui/src/components/IssueDocumentsSection.test.tsx`
- `pnpm --filter @paperclipai/db build`
- `pnpm exec vitest run server/src/__tests__/activity-service.test.ts
ui/src/components/IssueRunLedger.test.tsx`
- `pnpm --filter @paperclipai/ui typecheck`
- `pnpm --filter @paperclipai/server typecheck`
- `pnpm exec vitest run server/src/__tests__/activity-service.test.ts
server/src/__tests__/run-continuations.test.ts
ui/src/components/IssueRunLedger.test.tsx`
- `pnpm exec vitest run
server/src/__tests__/heartbeat-process-recovery.test.ts -t "treats a
plan document update"`
- `pnpm exec vitest run server/src/__tests__/activity-service.test.ts
server/src/__tests__/heartbeat-process-recovery.test.ts -t "activity
service|treats a plan document update"`
- Remote PR checks on head `e53b1a1d`: `verify`, `e2e`, `policy`, and
Snyk all passed.
- Confirmed `public-gh/master` is an ancestor of this branch after
fetching `public-gh master`.
- Confirmed `pnpm-lock.yaml` is not included in the branch diff.
- Confirmed migration `0058_wealthy_starbolt.sql` is ordered after
`0057` and uses `IF NOT EXISTS` guards for repeat application.
- Greptile inline review threads are resolved.
## Risks
- Medium risk: this touches heartbeat execution, liveness recovery,
activity rendering, issue routes, shared contracts, docs, and UI.
- Migration risk is mitigated by additive columns/indexes and idempotent
guards.
- Run-ledger liveness backfill is now asynchronous, so the first ledger
response can briefly show historical missing liveness until the
background backfill completes.
- UI screenshot coverage is not included in this packaging pass;
validation is currently through focused component tests.
> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.
## Model Used
- OpenAI Codex, GPT-5.4, local tool-use coding agent with terminal, git,
GitHub connector, GitHub CLI, and Paperclip API access.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
Screenshot note: no before/after screenshots were captured in this PR
packaging pass; the UI changes are covered by focused component tests
listed above.
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-20 06:01:49 -05:00
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE ,
2026-03-13 08:49:11 -05:00
joinPromptSections ,
2026-02-18 13:53:03 -06:00
runChildProcess ,
2026-03-03 08:45:26 -06:00
} from "@paperclipai/adapter-utils/server-utils" ;
2026-02-19 14:01:58 -06:00
import { parseCodexJsonl , isCodexUnknownSessionError } from "./parse.js" ;
2026-03-25 16:09:09 -07:00
import { pathExists , prepareManagedCodexHome , resolveManagedCodexHomeDir , resolveSharedCodexHomeDir } from "./codex-home.js" ;
2026-03-13 22:49:42 -05:00
import { resolveCodexDesiredSkillNames } from "./skills.js" ;
2026-04-11 07:36:42 -05:00
import { buildCodexExecArgs } from "./codex-args.js" ;
2026-02-18 13:53:03 -06:00
2026-03-03 16:06:12 -06:00
const __moduleDir = path . dirname ( fileURLToPath ( import . meta . url ) ) ;
2026-02-19 14:39:37 -06:00
const CODEX_ROLLOUT_NOISE_RE =
/^\d{4}-\d{2}-\d{2}T[^\s]+\s+ERROR\s+codex_core::rollout::list:\s+state db missing rollout path for thread\s+[a-z0-9-]+$/i ;
function stripCodexRolloutNoise ( text : string ) : string {
const parts = text . split ( /\r?\n/ ) ;
const kept : string [ ] = [ ] ;
for ( const part of parts ) {
const trimmed = part . trim ( ) ;
if ( ! trimmed ) {
kept . push ( part ) ;
continue ;
}
if ( CODEX_ROLLOUT_NOISE_RE . test ( trimmed ) ) continue ;
kept . push ( part ) ;
}
return kept . join ( "\n" ) ;
}
function firstNonEmptyLine ( text : string ) : string {
return (
text
. split ( /\r?\n/ )
. map ( ( line ) = > line . trim ( ) )
. find ( Boolean ) ? ? ""
) ;
}
2026-02-25 21:35:44 -06:00
function hasNonEmptyEnvValue ( env : Record < string , string > , key : string ) : boolean {
const raw = env [ key ] ;
return typeof raw === "string" && raw . trim ( ) . length > 0 ;
}
function resolveCodexBillingType ( env : Record < string , string > ) : "api" | "subscription" {
// Codex uses API-key auth when OPENAI_API_KEY is present; otherwise rely on local login/session auth.
return hasNonEmptyEnvValue ( env , "OPENAI_API_KEY" ) ? "api" : "subscription" ;
}
2026-03-14 22:00:12 -05:00
function resolveCodexBiller ( env : Record < string , string > , billingType : "api" | "subscription" ) : string {
const openAiCompatibleBiller = inferOpenAiCompatibleBiller ( env , "openai" ) ;
if ( openAiCompatibleBiller === "openrouter" ) return "openrouter" ;
return billingType === "subscription" ? "chatgpt" : openAiCompatibleBiller ? ? "openai" ;
}
2026-03-13 11:53:56 -05:00
async function isLikelyPaperclipRepoRoot ( candidate : string ) : Promise < boolean > {
const [ hasWorkspace , hasPackageJson , hasServerDir , hasAdapterUtilsDir ] = await Promise . all ( [
pathExists ( path . join ( candidate , "pnpm-workspace.yaml" ) ) ,
pathExists ( path . join ( candidate , "package.json" ) ) ,
pathExists ( path . join ( candidate , "server" ) ) ,
pathExists ( path . join ( candidate , "packages" , "adapter-utils" ) ) ,
] ) ;
return hasWorkspace && hasPackageJson && hasServerDir && hasAdapterUtilsDir ;
}
2026-03-18 14:07:24 -05:00
async function isLikelyPaperclipRuntimeSkillPath (
candidate : string ,
skillName : string ,
options : { requireSkillMarkdown? : boolean } = { } ,
) : Promise < boolean > {
2026-03-13 11:53:56 -05:00
if ( path . basename ( candidate ) !== skillName ) return false ;
const skillsRoot = path . dirname ( candidate ) ;
if ( path . basename ( skillsRoot ) !== "skills" ) return false ;
2026-03-18 14:07:24 -05:00
if ( options . requireSkillMarkdown !== false && ! ( await pathExists ( path . join ( candidate , "SKILL.md" ) ) ) ) {
return false ;
}
2026-03-13 11:53:56 -05:00
let cursor = path . dirname ( skillsRoot ) ;
for ( let depth = 0 ; depth < 6 ; depth += 1 ) {
if ( await isLikelyPaperclipRepoRoot ( cursor ) ) return true ;
const parent = path . dirname ( cursor ) ;
if ( parent === cursor ) break ;
cursor = parent ;
}
return false ;
2026-02-19 14:39:37 -06:00
}
2026-03-18 14:07:24 -05:00
async function pruneBrokenUnavailablePaperclipSkillSymlinks (
skillsHome : string ,
allowedSkillNames : Iterable < string > ,
onLog : AdapterExecutionContext [ "onLog" ] ,
) {
const allowed = new Set ( Array . from ( allowedSkillNames ) ) ;
const entries = await fs . readdir ( skillsHome , { withFileTypes : true } ) . catch ( ( ) = > [ ] ) ;
for ( const entry of entries ) {
if ( allowed . has ( entry . name ) || ! entry . isSymbolicLink ( ) ) continue ;
const target = path . join ( skillsHome , entry . name ) ;
const linkedPath = await fs . readlink ( target ) . catch ( ( ) = > null ) ;
if ( ! linkedPath ) continue ;
const resolvedLinkedPath = path . resolve ( path . dirname ( target ) , linkedPath ) ;
if ( await pathExists ( resolvedLinkedPath ) ) continue ;
if (
! ( await isLikelyPaperclipRuntimeSkillPath ( resolvedLinkedPath , entry . name , {
requireSkillMarkdown : false ,
} ) )
) {
continue ;
}
await fs . unlink ( target ) . catch ( ( ) = > { } ) ;
await onLog (
"stdout" ,
` [paperclip] Removed stale Codex skill " ${ entry . name } " from ${ skillsHome } \ n ` ,
) ;
}
}
2026-03-25 16:04:53 -07:00
function resolveCodexSkillsDir ( codexHome : string ) : string {
return path . join ( codexHome , "skills" ) ;
2026-03-19 07:15:36 -05:00
}
2026-03-12 15:44:44 -05:00
type EnsureCodexSkillsInjectedOptions = {
skillsHome? : string ;
2026-03-16 18:27:20 -05:00
skillsEntries? : Array < { key : string ; runtimeName : string ; source : string } > ;
2026-03-13 22:49:42 -05:00
desiredSkillNames? : string [ ] ;
2026-03-12 15:44:44 -05:00
linkSkill ? : ( source : string , target : string ) = > Promise < void > ;
} ;
2026-03-03 16:06:12 -06:00
2026-03-12 15:44:44 -05:00
export async function ensureCodexSkillsInjected (
onLog : AdapterExecutionContext [ "onLog" ] ,
options : EnsureCodexSkillsInjectedOptions = { } ,
) {
2026-03-15 07:05:01 -05:00
const allSkillsEntries = options . skillsEntries ? ? await readPaperclipRuntimeSkillEntries ( { } , __moduleDir ) ;
2026-03-13 22:49:42 -05:00
const desiredSkillNames =
2026-03-16 18:27:20 -05:00
options . desiredSkillNames ? ? allSkillsEntries . map ( ( entry ) = > entry . key ) ;
2026-03-13 22:49:42 -05:00
const desiredSet = new Set ( desiredSkillNames ) ;
2026-03-16 18:27:20 -05:00
const skillsEntries = allSkillsEntries . filter ( ( entry ) = > desiredSet . has ( entry . key ) ) ;
2026-03-12 15:44:44 -05:00
if ( skillsEntries . length === 0 ) return ;
2026-02-19 14:39:37 -06:00
2026-03-25 16:09:09 -07:00
const skillsHome = options . skillsHome ? ? resolveCodexSkillsDir ( resolveSharedCodexHomeDir ( ) ) ;
2026-02-19 14:39:37 -06:00
await fs . mkdir ( skillsHome , { recursive : true } ) ;
2026-03-12 15:44:44 -05:00
const linkSkill = options . linkSkill ;
for ( const entry of skillsEntries ) {
2026-03-16 18:27:20 -05:00
const target = path . join ( skillsHome , entry . runtimeName ) ;
2026-02-19 14:39:37 -06:00
try {
2026-03-13 11:53:56 -05:00
const existing = await fs . lstat ( target ) . catch ( ( ) = > null ) ;
if ( existing ? . isSymbolicLink ( ) ) {
const linkedPath = await fs . readlink ( target ) . catch ( ( ) = > null ) ;
const resolvedLinkedPath = linkedPath
? path . resolve ( path . dirname ( target ) , linkedPath )
: null ;
if (
resolvedLinkedPath &&
resolvedLinkedPath !== entry . source &&
2026-03-18 14:07:24 -05:00
( await isLikelyPaperclipRuntimeSkillPath ( resolvedLinkedPath , entry . runtimeName ) )
2026-03-13 11:53:56 -05:00
) {
await fs . unlink ( target ) ;
if ( linkSkill ) {
await linkSkill ( entry . source , target ) ;
} else {
await fs . symlink ( entry . source , target ) ;
}
await onLog (
2026-03-16 18:09:43 -05:00
"stdout" ,
2026-03-17 10:45:14 -05:00
` [paperclip] Repaired Codex skill " ${ entry . runtimeName } " into ${ skillsHome } \ n ` ,
2026-03-13 11:53:56 -05:00
) ;
continue ;
}
}
2026-03-12 15:44:44 -05:00
const result = await ensurePaperclipSkillSymlink ( entry . source , target , linkSkill ) ;
if ( result === "skipped" ) continue ;
2026-02-19 14:39:37 -06:00
await onLog (
2026-03-16 18:09:43 -05:00
"stdout" ,
2026-03-17 10:45:14 -05:00
` [paperclip] ${ result === "repaired" ? "Repaired" : "Injected" } Codex skill " ${ entry . runtimeName } " into ${ skillsHome } \ n ` ,
2026-02-19 14:39:37 -06:00
) ;
} catch ( err ) {
await onLog (
"stderr" ,
2026-03-16 18:27:20 -05:00
` [paperclip] Failed to inject Codex skill " ${ entry . key } " into ${ skillsHome } : ${ err instanceof Error ? err.message : String ( err ) } \ n ` ,
2026-02-19 14:39:37 -06:00
) ;
}
}
2026-03-18 14:07:24 -05:00
await pruneBrokenUnavailablePaperclipSkillSymlinks (
skillsHome ,
skillsEntries . map ( ( entry ) = > entry . runtimeName ) ,
onLog ,
) ;
2026-02-19 14:39:37 -06:00
}
2026-02-18 13:53:03 -06:00
export async function execute ( ctx : AdapterExecutionContext ) : Promise < AdapterExecutionResult > {
2026-03-19 11:20:36 -05:00
const { runId , agent , runtime , config , context , onLog , onMeta , onSpawn , authToken } = ctx ;
2026-02-18 13:53:03 -06:00
const promptTemplate = asString (
config . promptTemplate ,
[codex] Add run liveness continuations (#4083)
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies.
> - Heartbeat runs are the control-plane record of each agent execution
window.
> - Long-running local agents can exhaust context or stop while still
holding useful next-step state.
> - Operators need that stop reason, next action, and continuation path
to be durable and visible.
> - This pull request adds run liveness metadata, continuation
summaries, and UI surfaces for issue run ledgers.
> - The benefit is that interrupted or long-running work can resume with
clearer context instead of losing the agent's last useful handoff.
## What Changed
- Added heartbeat-run liveness fields, continuation attempt tracking,
and an idempotent `0058` migration.
- Added server services and tests for run liveness, continuation
summaries, stop metadata, and activity backfill.
- Wired local and HTTP adapters to surface continuation/liveness context
through shared adapter utilities.
- Added shared constants, validators, and heartbeat types for liveness
continuation state.
- Added issue-detail UI surfaces for continuation handoffs and the run
ledger, with component tests.
- Updated agent runtime docs, heartbeat protocol docs, prompt guidance,
onboarding assets, and skills instructions to explain continuation
behavior.
- Addressed Greptile feedback by scoping document evidence by run,
excluding system continuation-summary documents from liveness evidence,
importing shared liveness types, surfacing hidden ledger run counts,
documenting bounded retry behavior, and moving run-ledger liveness
backfill off the request path.
## Verification
- `pnpm exec vitest run packages/adapter-utils/src/server-utils.test.ts
server/src/__tests__/run-continuations.test.ts
server/src/__tests__/run-liveness.test.ts
server/src/__tests__/activity-service.test.ts
server/src/__tests__/documents-service.test.ts
server/src/__tests__/issue-continuation-summary.test.ts
server/src/services/heartbeat-stop-metadata.test.ts
ui/src/components/IssueRunLedger.test.tsx
ui/src/components/IssueContinuationHandoff.test.tsx
ui/src/components/IssueDocumentsSection.test.tsx`
- `pnpm --filter @paperclipai/db build`
- `pnpm exec vitest run server/src/__tests__/activity-service.test.ts
ui/src/components/IssueRunLedger.test.tsx`
- `pnpm --filter @paperclipai/ui typecheck`
- `pnpm --filter @paperclipai/server typecheck`
- `pnpm exec vitest run server/src/__tests__/activity-service.test.ts
server/src/__tests__/run-continuations.test.ts
ui/src/components/IssueRunLedger.test.tsx`
- `pnpm exec vitest run
server/src/__tests__/heartbeat-process-recovery.test.ts -t "treats a
plan document update"`
- `pnpm exec vitest run server/src/__tests__/activity-service.test.ts
server/src/__tests__/heartbeat-process-recovery.test.ts -t "activity
service|treats a plan document update"`
- Remote PR checks on head `e53b1a1d`: `verify`, `e2e`, `policy`, and
Snyk all passed.
- Confirmed `public-gh/master` is an ancestor of this branch after
fetching `public-gh master`.
- Confirmed `pnpm-lock.yaml` is not included in the branch diff.
- Confirmed migration `0058_wealthy_starbolt.sql` is ordered after
`0057` and uses `IF NOT EXISTS` guards for repeat application.
- Greptile inline review threads are resolved.
## Risks
- Medium risk: this touches heartbeat execution, liveness recovery,
activity rendering, issue routes, shared contracts, docs, and UI.
- Migration risk is mitigated by additive columns/indexes and idempotent
guards.
- Run-ledger liveness backfill is now asynchronous, so the first ledger
response can briefly show historical missing liveness until the
background backfill completes.
- UI screenshot coverage is not included in this packaging pass;
validation is currently through focused component tests.
> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.
## Model Used
- OpenAI Codex, GPT-5.4, local tool-use coding agent with terminal, git,
GitHub connector, GitHub CLI, and Paperclip API access.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
Screenshot note: no before/after screenshots were captured in this PR
packaging pass; the UI changes are covered by focused component tests
listed above.
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-20 06:01:49 -05:00
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE ,
2026-02-18 13:53:03 -06:00
) ;
const command = asString ( config . command , "codex" ) ;
const model = asString ( config . model , "" ) ;
2026-02-25 08:38:58 -06:00
const workspaceContext = parseObject ( context . paperclipWorkspace ) ;
const workspaceCwd = asString ( workspaceContext . cwd , "" ) ;
const workspaceSource = asString ( workspaceContext . source , "" ) ;
2026-03-10 10:58:38 -05:00
const workspaceStrategy = asString ( workspaceContext . strategy , "" ) ;
2026-02-25 08:38:58 -06:00
const workspaceId = asString ( workspaceContext . workspaceId , "" ) ;
const workspaceRepoUrl = asString ( workspaceContext . repoUrl , "" ) ;
const workspaceRepoRef = asString ( workspaceContext . repoRef , "" ) ;
2026-03-10 10:58:38 -05:00
const workspaceBranch = asString ( workspaceContext . branchName , "" ) ;
const workspaceWorktreePath = asString ( workspaceContext . worktreePath , "" ) ;
2026-03-14 00:36:53 -07:00
const agentHome = asString ( workspaceContext . agentHome , "" ) ;
2026-02-25 21:35:44 -06:00
const workspaceHints = Array . isArray ( context . paperclipWorkspaces )
? context . paperclipWorkspaces . filter (
( value ) : value is Record < string , unknown > = > typeof value === "object" && value !== null ,
)
: [ ] ;
2026-03-10 10:58:38 -05:00
const runtimeServiceIntents = Array . isArray ( context . paperclipRuntimeServiceIntents )
? context . paperclipRuntimeServiceIntents . filter (
( value ) : value is Record < string , unknown > = > typeof value === "object" && value !== null ,
)
: [ ] ;
const runtimeServices = Array . isArray ( context . paperclipRuntimeServices )
? context . paperclipRuntimeServices . filter (
( value ) : value is Record < string , unknown > = > typeof value === "object" && value !== null ,
)
: [ ] ;
const runtimePrimaryUrl = asString ( context . paperclipRuntimePrimaryUrl , "" ) ;
2026-02-25 21:35:44 -06:00
const configuredCwd = asString ( config . cwd , "" ) ;
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd . length > 0 ;
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd ;
const cwd = effectiveWorkspaceCwd || configuredCwd || process . cwd ( ) ;
2026-02-18 13:53:03 -06:00
const envConfig = parseObject ( config . env ) ;
2026-03-13 11:53:56 -05:00
const configuredCodexHome =
typeof envConfig . CODEX_HOME === "string" && envConfig . CODEX_HOME . trim ( ) . length > 0
? path . resolve ( envConfig . CODEX_HOME . trim ( ) )
: null ;
2026-03-15 07:05:01 -05:00
const codexSkillEntries = await readPaperclipRuntimeSkillEntries ( config , __moduleDir ) ;
const desiredSkillNames = resolveCodexDesiredSkillNames ( config , codexSkillEntries ) ;
2026-03-13 11:53:56 -05:00
await ensureAbsoluteDirectory ( cwd , { createIfMissing : true } ) ;
2026-03-20 14:44:27 -05:00
const preparedManagedCodexHome =
configuredCodexHome ? null : await prepareManagedCodexHome ( process . env , onLog , agent . companyId ) ;
const defaultCodexHome = resolveManagedCodexHomeDir ( process . env , agent . companyId ) ;
const effectiveCodexHome = configuredCodexHome ? ? preparedManagedCodexHome ? ? defaultCodexHome ;
2026-03-20 14:25:18 -05:00
await fs . mkdir ( effectiveCodexHome , { recursive : true } ) ;
2026-03-25 20:46:05 -07:00
// Inject skills into the same CODEX_HOME that Codex will actually run with
// (managed home in the default case, or an explicit override from adapter config).
2026-03-25 16:04:53 -07:00
const codexSkillsDir = resolveCodexSkillsDir ( effectiveCodexHome ) ;
2026-03-13 11:53:56 -05:00
await ensureCodexSkillsInjected (
onLog ,
2026-03-18 14:38:39 -05:00
{
2026-03-25 15:55:51 -07:00
skillsHome : codexSkillsDir ,
2026-03-18 14:38:39 -05:00
skillsEntries : codexSkillEntries ,
desiredSkillNames ,
} ,
2026-03-13 11:53:56 -05:00
) ;
2026-02-18 16:46:45 -06:00
const hasExplicitApiKey =
typeof envConfig . PAPERCLIP_API_KEY === "string" && envConfig . PAPERCLIP_API_KEY . trim ( ) . length > 0 ;
2026-02-18 13:53:03 -06:00
const env : Record < string , string > = { . . . buildPaperclipEnv ( agent ) } ;
2026-03-18 14:38:39 -05:00
env . CODEX_HOME = effectiveCodexHome ;
2026-02-19 09:09:50 -06:00
env . PAPERCLIP_RUN_ID = runId ;
const wakeTaskId =
( typeof context . taskId === "string" && context . taskId . trim ( ) . length > 0 && context . taskId . trim ( ) ) ||
( typeof context . issueId === "string" && context . issueId . trim ( ) . length > 0 && context . issueId . trim ( ) ) ||
null ;
const wakeReason =
typeof context . wakeReason === "string" && context . wakeReason . trim ( ) . length > 0
? context . wakeReason . trim ( )
: null ;
2026-02-20 10:32:07 -06:00
const wakeCommentId =
( typeof context . wakeCommentId === "string" && context . wakeCommentId . trim ( ) . length > 0 && context . wakeCommentId . trim ( ) ) ||
( typeof context . commentId === "string" && context . commentId . trim ( ) . length > 0 && context . commentId . trim ( ) ) ||
null ;
2026-02-19 13:02:53 -06:00
const approvalId =
typeof context . approvalId === "string" && context . approvalId . trim ( ) . length > 0
? context . approvalId . trim ( )
: null ;
const approvalStatus =
typeof context . approvalStatus === "string" && context . approvalStatus . trim ( ) . length > 0
? context . approvalStatus . trim ( )
: null ;
const linkedIssueIds = Array . isArray ( context . issueIds )
? context . issueIds . filter ( ( value ) : value is string = > typeof value === "string" && value . trim ( ) . length > 0 )
: [ ] ;
2026-03-28 09:55:41 -05:00
const wakePayloadJson = stringifyPaperclipWakePayload ( context . paperclipWake ) ;
2026-02-19 09:09:50 -06:00
if ( wakeTaskId ) {
env . PAPERCLIP_TASK_ID = wakeTaskId ;
}
if ( wakeReason ) {
env . PAPERCLIP_WAKE_REASON = wakeReason ;
}
2026-02-20 10:32:07 -06:00
if ( wakeCommentId ) {
env . PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId ;
}
2026-02-19 13:02:53 -06:00
if ( approvalId ) {
env . PAPERCLIP_APPROVAL_ID = approvalId ;
}
if ( approvalStatus ) {
env . PAPERCLIP_APPROVAL_STATUS = approvalStatus ;
}
if ( linkedIssueIds . length > 0 ) {
env . PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds . join ( "," ) ;
}
2026-03-28 09:55:41 -05:00
if ( wakePayloadJson ) {
env . PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson ;
}
2026-02-25 21:35:44 -06:00
if ( effectiveWorkspaceCwd ) {
env . PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd ;
2026-02-25 08:38:58 -06:00
}
if ( workspaceSource ) {
env . PAPERCLIP_WORKSPACE_SOURCE = workspaceSource ;
}
2026-03-10 10:58:38 -05:00
if ( workspaceStrategy ) {
env . PAPERCLIP_WORKSPACE_STRATEGY = workspaceStrategy ;
}
2026-02-25 08:38:58 -06:00
if ( workspaceId ) {
env . PAPERCLIP_WORKSPACE_ID = workspaceId ;
}
if ( workspaceRepoUrl ) {
env . PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl ;
}
if ( workspaceRepoRef ) {
env . PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef ;
}
2026-03-10 10:58:38 -05:00
if ( workspaceBranch ) {
env . PAPERCLIP_WORKSPACE_BRANCH = workspaceBranch ;
}
if ( workspaceWorktreePath ) {
env . PAPERCLIP_WORKSPACE_WORKTREE_PATH = workspaceWorktreePath ;
}
2026-03-14 00:36:53 -07:00
if ( agentHome ) {
env . AGENT_HOME = agentHome ;
}
2026-02-25 21:35:44 -06:00
if ( workspaceHints . length > 0 ) {
env . PAPERCLIP_WORKSPACES_JSON = JSON . stringify ( workspaceHints ) ;
}
2026-03-10 10:58:38 -05:00
if ( runtimeServiceIntents . length > 0 ) {
env . PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON = JSON . stringify ( runtimeServiceIntents ) ;
}
if ( runtimeServices . length > 0 ) {
env . PAPERCLIP_RUNTIME_SERVICES_JSON = JSON . stringify ( runtimeServices ) ;
}
if ( runtimePrimaryUrl ) {
env . PAPERCLIP_RUNTIME_PRIMARY_URL = runtimePrimaryUrl ;
}
2026-02-18 13:53:03 -06:00
for ( const [ k , v ] of Object . entries ( envConfig ) ) {
if ( typeof v === "string" ) env [ k ] = v ;
}
2026-02-18 16:46:45 -06:00
if ( ! hasExplicitApiKey && authToken ) {
env . PAPERCLIP_API_KEY = authToken ;
}
2026-03-14 22:00:12 -05:00
const effectiveEnv = Object . fromEntries (
Object . entries ( { . . . process . env , . . . env } ) . filter (
( entry ) : entry is [ string , string ] = > typeof entry [ 1 ] === "string" ,
) ,
) ;
const billingType = resolveCodexBillingType ( effectiveEnv ) ;
const runtimeEnv = ensurePathInEnv ( effectiveEnv ) ;
2026-02-18 13:53:03 -06:00
await ensureCommandResolvable ( command , cwd , runtimeEnv ) ;
2026-03-28 15:42:14 -05:00
const resolvedCommand = await resolveCommandForLogs ( command , cwd , runtimeEnv ) ;
const loggedEnv = buildInvocationEnvForLogs ( env , {
runtimeEnv ,
includeRuntimeKeys : [ "HOME" ] ,
resolvedCommand ,
} ) ;
2026-02-18 13:53:03 -06:00
2026-02-20 10:32:07 -06:00
const timeoutSec = asNumber ( config . timeoutSec , 0 ) ;
2026-02-18 13:53:03 -06:00
const graceSec = asNumber ( config . graceSec , 20 ) ;
2026-02-19 14:01:58 -06:00
const runtimeSessionParams = parseObject ( runtime . sessionParams ) ;
const runtimeSessionId = asString ( runtimeSessionParams . sessionId , runtime . sessionId ? ? "" ) ;
const runtimeSessionCwd = asString ( runtimeSessionParams . cwd , "" ) ;
const canResumeSession =
runtimeSessionId . length > 0 &&
( runtimeSessionCwd . length === 0 || path . resolve ( runtimeSessionCwd ) === path . resolve ( cwd ) ) ;
const sessionId = canResumeSession ? runtimeSessionId : null ;
if ( runtimeSessionId && ! canResumeSession ) {
await onLog (
2026-03-18 21:16:37 -05:00
"stdout" ,
2026-02-19 14:01:58 -06:00
` [paperclip] Codex session " ${ runtimeSessionId } " was saved for cwd " ${ runtimeSessionCwd } " and will not be resumed in " ${ cwd } ". \ n ` ,
) ;
}
2026-02-26 16:34:15 -06:00
const instructionsFilePath = asString ( config . instructionsFilePath , "" ) . trim ( ) ;
2026-03-02 16:43:59 -06:00
const instructionsDir = instructionsFilePath ? ` ${ path . dirname ( instructionsFilePath ) } / ` : "" ;
2026-02-26 16:34:15 -06:00
let instructionsPrefix = "" ;
2026-03-13 08:49:11 -05:00
let instructionsChars = 0 ;
2026-02-26 16:34:15 -06:00
if ( instructionsFilePath ) {
try {
const instructionsContents = await fs . readFile ( instructionsFilePath , "utf8" ) ;
instructionsPrefix =
` ${ instructionsContents } \ n \ n ` +
` The above agent instructions were loaded from ${ instructionsFilePath } . ` +
` Resolve any relative file references from ${ instructionsDir } . \ n \ n ` ;
2026-03-13 08:49:11 -05:00
instructionsChars = instructionsPrefix . length ;
2026-02-26 16:34:15 -06:00
} catch ( err ) {
const reason = err instanceof Error ? err.message : String ( err ) ;
await onLog (
2026-03-18 21:16:37 -05:00
"stdout" ,
2026-02-26 16:34:15 -06:00
` [paperclip] Warning: could not read agent instructions file " ${ instructionsFilePath } ": ${ reason } \ n ` ,
) ;
}
}
2026-03-23 16:55:10 -05:00
const repoAgentsNote =
"Codex exec automatically applies repo-scoped AGENTS.md instructions from the current workspace; Paperclip does not currently suppress that discovery." ;
2026-03-28 10:33:40 -05:00
const bootstrapPromptTemplate = asString ( config . bootstrapPromptTemplate , "" ) ;
const templateData = {
agentId : agent.id ,
companyId : agent.companyId ,
runId ,
company : { id : agent.companyId } ,
agent ,
run : { id : runId , source : "on_demand" } ,
context ,
} ;
const renderedBootstrapPrompt =
! sessionId && bootstrapPromptTemplate . trim ( ) . length > 0
? renderTemplate ( bootstrapPromptTemplate , templateData ) . trim ( )
: "" ;
const wakePrompt = renderPaperclipWakePrompt ( context . paperclipWake , { resumedSession : Boolean ( sessionId ) } ) ;
const shouldUseResumeDeltaPrompt = Boolean ( sessionId ) && wakePrompt . length > 0 ;
const promptInstructionsPrefix = shouldUseResumeDeltaPrompt ? "" : instructionsPrefix ;
instructionsChars = promptInstructionsPrefix . length ;
2026-03-02 16:43:59 -06:00
const commandNotes = ( ( ) = > {
2026-03-23 16:55:10 -05:00
if ( ! instructionsFilePath ) {
return [ repoAgentsNote ] ;
}
2026-03-02 16:43:59 -06:00
if ( instructionsPrefix . length > 0 ) {
2026-03-28 10:33:40 -05:00
if ( shouldUseResumeDeltaPrompt ) {
return [
` Loaded agent instructions from ${ instructionsFilePath } ` ,
"Skipped stdin instruction reinjection because an existing Codex session is being resumed with a wake delta." ,
repoAgentsNote ,
] ;
}
2026-03-02 16:43:59 -06:00
return [
` Loaded agent instructions from ${ instructionsFilePath } ` ,
` Prepended instructions + path directive to stdin prompt (relative references from ${ instructionsDir } ). ` ,
2026-03-23 16:55:10 -05:00
repoAgentsNote ,
2026-03-02 16:43:59 -06:00
] ;
}
return [
` Configured instructionsFilePath ${ instructionsFilePath } , but file could not be read; continuing without injected instructions. ` ,
2026-03-23 16:55:10 -05:00
repoAgentsNote ,
2026-03-02 16:43:59 -06:00
] ;
} ) ( ) ;
2026-03-28 10:33:40 -05:00
const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate ( promptTemplate , templateData ) ;
2026-03-13 08:49:11 -05:00
const sessionHandoffNote = asString ( context . paperclipSessionHandoffMarkdown , "" ) . trim ( ) ;
const prompt = joinPromptSections ( [
2026-03-28 10:33:40 -05:00
promptInstructionsPrefix ,
2026-03-13 08:49:11 -05:00
renderedBootstrapPrompt ,
2026-03-28 09:55:41 -05:00
wakePrompt ,
2026-03-13 08:49:11 -05:00
sessionHandoffNote ,
renderedPrompt ,
] ) ;
const promptMetrics = {
promptChars : prompt.length ,
instructionsChars ,
bootstrapPromptChars : renderedBootstrapPrompt.length ,
2026-03-28 09:55:41 -05:00
wakePromptChars : wakePrompt.length ,
2026-03-13 08:49:11 -05:00
sessionHandoffChars : sessionHandoffNote.length ,
heartbeatPromptChars : renderedPrompt.length ,
} ;
2026-02-18 13:53:03 -06:00
2026-02-19 14:01:58 -06:00
const runAttempt = async ( resumeSessionId : string | null ) = > {
2026-04-11 07:36:42 -05:00
const execArgs = buildCodexExecArgs ( config , { resumeSessionId } ) ;
const args = execArgs . args ;
const commandNotesWithFastMode =
execArgs . fastModeIgnoredReason == null
? commandNotes
: [ . . . commandNotes , execArgs . fastModeIgnoredReason ] ;
2026-02-19 14:01:58 -06:00
if ( onMeta ) {
await onMeta ( {
adapterType : "codex_local" ,
2026-03-28 15:42:14 -05:00
command : resolvedCommand ,
2026-02-19 14:01:58 -06:00
cwd ,
2026-04-11 07:36:42 -05:00
commandNotes : commandNotesWithFastMode ,
2026-02-19 14:01:58 -06:00
commandArgs : args.map ( ( value , idx ) = > {
2026-02-19 14:39:37 -06:00
if ( idx === args . length - 1 && value !== "-" ) return ` <prompt ${ prompt . length } chars> ` ;
2026-02-19 14:01:58 -06:00
return value ;
} ) ,
2026-03-28 15:42:14 -05:00
env : loggedEnv ,
2026-02-19 14:01:58 -06:00
prompt ,
2026-03-13 08:49:11 -05:00
promptMetrics ,
2026-02-19 14:01:58 -06:00
context ,
} ) ;
}
2026-02-18 13:53:03 -06:00
2026-02-19 14:01:58 -06:00
const proc = await runChildProcess ( runId , command , args , {
2026-02-18 13:53:03 -06:00
cwd ,
2026-02-19 14:01:58 -06:00
env ,
2026-02-19 14:39:37 -06:00
stdin : prompt ,
2026-02-19 14:01:58 -06:00
timeoutSec ,
graceSec ,
2026-03-19 11:20:36 -05:00
onSpawn ,
2026-02-19 14:39:37 -06:00
onLog : async ( stream , chunk ) = > {
if ( stream !== "stderr" ) {
await onLog ( stream , chunk ) ;
return ;
}
const cleaned = stripCodexRolloutNoise ( chunk ) ;
if ( ! cleaned . trim ( ) ) return ;
await onLog ( stream , cleaned ) ;
} ,
2026-02-18 13:53:03 -06:00
} ) ;
2026-02-19 14:39:37 -06:00
const cleanedStderr = stripCodexRolloutNoise ( proc . stderr ) ;
2026-02-19 14:01:58 -06:00
return {
2026-02-19 14:39:37 -06:00
proc : {
. . . proc ,
stderr : cleanedStderr ,
} ,
rawStderr : proc.stderr ,
2026-02-19 14:01:58 -06:00
parsed : parseCodexJsonl ( proc . stdout ) ,
} ;
} ;
2026-02-18 13:53:03 -06:00
2026-02-19 14:01:58 -06:00
const toResult = (
2026-02-19 14:39:37 -06:00
attempt : { proc : { exitCode : number | null ; signal : string | null ; timedOut : boolean ; stdout : string ; stderr : string } ; rawStderr : string ; parsed : ReturnType < typeof parseCodexJsonl > } ,
2026-02-19 14:01:58 -06:00
clearSessionOnMissingSession = false ,
) : AdapterExecutionResult = > {
if ( attempt . proc . timedOut ) {
return {
exitCode : attempt.proc.exitCode ,
signal : attempt.proc.signal ,
timedOut : true ,
errorMessage : ` Timed out after ${ timeoutSec } s ` ,
clearSession : clearSessionOnMissingSession ,
} ;
}
const resolvedSessionId = attempt . parsed . sessionId ? ? runtimeSessionId ? ? runtime . sessionId ? ? null ;
const resolvedSessionParams = resolvedSessionId
2026-02-25 08:38:58 -06:00
? ( {
sessionId : resolvedSessionId ,
cwd ,
. . . ( workspaceId ? { workspaceId } : { } ) ,
. . . ( workspaceRepoUrl ? { repoUrl : workspaceRepoUrl } : { } ) ,
. . . ( workspaceRepoRef ? { repoRef : workspaceRepoRef } : { } ) ,
} as Record < string , unknown > )
2026-02-19 14:01:58 -06:00
: null ;
2026-02-19 14:39:37 -06:00
const parsedError = typeof attempt . parsed . errorMessage === "string" ? attempt . parsed . errorMessage . trim ( ) : "" ;
const stderrLine = firstNonEmptyLine ( attempt . proc . stderr ) ;
const fallbackErrorMessage =
parsedError ||
stderrLine ||
` Codex exited with code ${ attempt . proc . exitCode ? ? - 1 } ` ;
2026-02-18 13:53:03 -06:00
return {
2026-02-19 14:01:58 -06:00
exitCode : attempt.proc.exitCode ,
signal : attempt.proc.signal ,
timedOut : false ,
errorMessage :
( attempt . proc . exitCode ? ? 0 ) === 0
? null
2026-02-19 14:39:37 -06:00
: fallbackErrorMessage ,
2026-02-19 14:01:58 -06:00
usage : attempt.parsed.usage ,
sessionId : resolvedSessionId ,
sessionParams : resolvedSessionParams ,
sessionDisplayId : resolvedSessionId ,
provider : "openai" ,
2026-03-14 22:00:12 -05:00
biller : resolveCodexBiller ( effectiveEnv , billingType ) ,
2026-02-19 14:01:58 -06:00
model ,
2026-02-25 21:35:44 -06:00
billingType ,
2026-02-19 14:01:58 -06:00
costUsd : null ,
resultJson : {
stdout : attempt.proc.stdout ,
stderr : attempt.proc.stderr ,
} ,
summary : attempt.parsed.summary ,
clearSession : Boolean ( clearSessionOnMissingSession && ! resolvedSessionId ) ,
2026-02-18 13:53:03 -06:00
} ;
2026-02-19 14:01:58 -06:00
} ;
2026-02-18 13:53:03 -06:00
2026-02-19 14:01:58 -06:00
const initial = await runAttempt ( sessionId ) ;
if (
sessionId &&
! initial . proc . timedOut &&
( initial . proc . exitCode ? ? 0 ) !== 0 &&
2026-02-19 14:39:37 -06:00
isCodexUnknownSessionError ( initial . proc . stdout , initial . rawStderr )
2026-02-19 14:01:58 -06:00
) {
await onLog (
2026-03-18 21:16:37 -05:00
"stdout" ,
2026-02-19 14:01:58 -06:00
` [paperclip] Codex resume session " ${ sessionId } " is unavailable; retrying with a fresh session. \ n ` ,
) ;
const retry = await runAttempt ( null ) ;
return toResult ( retry , true ) ;
}
2026-02-18 13:53:03 -06:00
2026-02-19 14:01:58 -06:00
return toResult ( initial ) ;
2026-02-18 13:53:03 -06:00
}