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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .oxlintrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"automation/prefer-tagged-error-handling": "error",
"automation/no-ambient-nondeterminism": "error",
"anti-slop/no-reflect-apply": "error",
"anti-slop/no-runtime-typeof": "error",
"typescript/consistent-type-imports": ["error", { "fixStyle": "inline-type-imports" }],
"typescript/no-import-type-side-effects": "error",
"import/no-duplicates": "error",
Expand Down Expand Up @@ -76,6 +77,7 @@
"anti-slop/no-module-mocking": "off",
"anti-slop/no-object-parameters": "off",
"anti-slop/no-reflect-apply": "off",
"anti-slop/no-runtime-typeof": "off",
"automation/no-disable-validation": "off",
"automation/no-shadowed-standard-array-static": "off",
"automation/no-silent-error-swallow": "off",
Expand Down
14 changes: 4 additions & 10 deletions packages/fold-agent/src/Bin/ManagedBinaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { accessSync, constants, statSync } from 'node:fs'
import { delimiter, join } from 'node:path'
import { promisify } from 'node:util'

import { Cause, Effect, FileSystem, Schema } from 'effect'
import { Cause, Effect, FileSystem, Predicate, Schema } from 'effect'

import { managedBinaryRegistry, type ManagedBinaryAsset, type ManagedBinaryDefinition } from './Registry'

Expand Down Expand Up @@ -149,13 +149,9 @@ const defaultDownload: DownloadSeam = (url) =>

/** The ONE mapper from execFile rejections to the typed exec error. */
const execErrorFrom = (command: string, args: ReadonlyArray<string>, cause: unknown): BinaryExecError => {
const stderr =
typeof cause === 'object' && cause !== null && 'stderr' in cause && typeof cause.stderr === 'string'
? cause.stderr.trim()
: ''
const reason = cause instanceof Error ? cause.message : String(cause)
const reason = Predicate.isError(cause) ? cause.message : String(cause)
return new BinaryExecError({
message: `${command} ${args.join(' ')}: ${reason}${stderr === '' ? '' : ` (${stderr.slice(0, 400)})`}`,
message: `${command} ${args.join(' ')}: ${reason}`,
})
}

Expand Down Expand Up @@ -410,9 +406,7 @@ const resolveOne = (context: ResolveContext, definition: ManagedBinaryDefinition

/** Human-readable message for one squashed cause value (typed errors carry `message`; guard, never cast). */
const failureMessageOf = (value: unknown): string =>
typeof value === 'object' && value !== null && 'message' in value && typeof value.message === 'string'
? value.message
: String(value)
Predicate.isError(value) ? value.message : String(value)

/** One binary's resolution, degraded to `unavailable` on ANY failure or defect (capture, then keep going). */
const resolveOneNeverFailing = (
Expand Down
53 changes: 27 additions & 26 deletions packages/fold-agent/src/Compatibility/CodexPlugins.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createHash } from 'node:crypto'

import { Effect, FileSystem, Path, Schema } from 'effect'
import { Effect, FileSystem, Option, Path, Schema } from 'effect'

export const CodexPluginDiagnostic = Schema.Struct({
stage: Schema.Literals(['config', 'cache', 'manifest']),
Expand Down Expand Up @@ -53,8 +53,23 @@ type SemanticVersion = {
readonly prerelease: Array<string>
}

const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value)
const RelativeSkillRoot = Schema.String.check(
Schema.makeFilter((value: string) =>
!value.startsWith('./') ||
value === './' ||
value.includes('\\') ||
value.includes('\0') ||
value.split('/').includes('..')
? 'skill root must be a relative path without traversal'
: undefined,
),
)
const PluginManifest = Schema.Struct({
name: Schema.String,
skills: Schema.optionalKey(Schema.Union([RelativeSkillRoot, Schema.Array(Schema.Unknown)])),
})
const decodePluginManifest = Schema.decodeUnknownOption(Schema.fromJsonString(PluginManifest))
const decodeRelativeSkillRoot = Schema.decodeUnknownOption(RelativeSkillRoot)

const parseSemanticVersion = (value: string): SemanticVersion | null => {
const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(value)
Expand Down Expand Up @@ -91,18 +106,8 @@ const selectedVersion = (versions: ReadonlyArray<string>): string | null => {
)
}

const safeRelativeSkillRoot = (value: unknown): string | null => {
if (
typeof value !== 'string' ||
!value.startsWith('./') ||
value === './' ||
value.includes('\\') ||
value.includes('\0')
)
return null
if (value.split('/').includes('..')) return null
return value
}
const safeRelativeSkillRoot = (value: unknown): string | null =>
Option.getOrNull(decodeRelativeSkillRoot(value))

export const discoverCodexPluginSkillRoots = (options: CodexPluginOptions) =>
Effect.gen(function* () {
Expand All @@ -124,32 +129,28 @@ export const discoverCodexPluginSkillRoots = (options: CodexPluginOptions) =>
const version = selectedVersion(directories)
if (version === null) continue
const bundle = path.resolve(cachePath, version)
let manifest: unknown = null
let manifest: typeof PluginManifest.Type | null = null
let manifestPath = ''
for (const relativePath of ['plugin.json', '.codex-plugin/plugin.json', '.claude-plugin/plugin.json']) {
const candidate = path.join(bundle, relativePath)
const contents = yield* fs.readFileString(candidate).pipe(Effect.catch(() => Effect.succeed(null)))
if (contents === null) continue
manifestPath = candidate
try {
manifest = JSON.parse(contents)
} catch {
const decoded = Option.getOrUndefined(decodePluginManifest(contents))
if (decoded === undefined) {
diagnostics.push({ stage: 'manifest', code: 'manifest_parse_failed', path: candidate })
break
}
manifest = decoded
break
}
if (!isRecord(manifest)) continue
if (manifest === null) continue
const record = manifest
if (record.name !== plugin.name) {
diagnostics.push({ stage: 'manifest', code: 'manifest_name_mismatch', path: manifestPath })
continue
}
const declared =
record.skills === undefined
? ['./skills']
: Array.isArray(record.skills)
? record.skills
: [record.skills]
const declared = record.skills === undefined ? ['./skills'] : Array.isArray(record.skills) ? record.skills : [record.skills]
for (const value of declared) {
const relativeRoot = safeRelativeSkillRoot(value)
if (relativeRoot === null) {
Expand Down
21 changes: 9 additions & 12 deletions packages/fold-agent/src/Compatibility/CodexSkills.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { homedir } from 'node:os'

import { SkillNotFoundError, type Skill, type SkillMeta, type SkillSourceService } from '@humanlayer/fold-core'
import { Effect, FileSystem, Path } from 'effect'
import { Effect, FileSystem, Option, Path, Schema } from 'effect'
import { parse as parseYaml } from 'yaml'

export type CodexSkillOptions = {
Expand Down Expand Up @@ -47,8 +47,11 @@ const ancestorSkillRoots = (cwd: string, home: string | null): Effect.Effect<Rea
return roots
})

const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value)
const SkillFrontmatter = Schema.Struct({
name: Schema.optionalKey(Schema.String),
description: Schema.String,
})
const decodeSkillFrontmatter = Schema.decodeUnknownOption(SkillFrontmatter)

const loadSkill = (
skillPath: string,
Expand All @@ -63,16 +66,10 @@ const loadSkill = (
if (!normalized.startsWith('---\n')) return null
const end = normalized.indexOf('\n---', 4)
if (end < 0) return null
const parsed: unknown = parseYaml(normalized.slice(4, end))
if (
!isRecord(parsed) ||
typeof parsed.description !== 'string' ||
parsed.description.trim().length === 0
)
return null
const parsed = Option.getOrUndefined(decodeSkillFrontmatter(parseYaml(normalized.slice(4, end))))
if (parsed === undefined || parsed.description.trim().length === 0) return null
const directory = path.dirname(skillPath)
const rawName =
typeof parsed.name === 'string' && parsed.name.length > 0 ? parsed.name : path.basename(directory)
const rawName = parsed.name !== undefined && parsed.name.length > 0 ? parsed.name : path.basename(directory)
const name = namespace === undefined ? rawName : `${namespace}:${rawName}`
return {
name,
Expand Down
54 changes: 32 additions & 22 deletions packages/fold-agent/src/Compatibility/GrokPlugins.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { homedir } from 'node:os'

import { Effect, FileSystem, Path, Schema } from 'effect'
import { Effect, FileSystem, Option, Path, Schema } from 'effect'

export const GrokPluginDiagnostic = Schema.Struct({
stage: Schema.Literals(['manifest', 'discovery']),
Expand All @@ -19,8 +19,26 @@ export type GrokPluginOptions = {
readonly configuredPaths?: ReadonlyArray<string>
}

const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value)
const RelativeSkillPath = Schema.String.check(
Schema.makeFilter((value: string) => {
if (value.length === 0 || value.includes('\\') || value.includes('\0')) {
return 'skill path must be a relative path without traversal'
}
const normalized = value.replace(/^\.\//, '')
return normalized.length === 0 ||
normalized.startsWith('/') ||
/^[A-Za-z]:\//.test(normalized) ||
normalized.split('/').includes('..')
? 'skill path must be a relative path without traversal'
: undefined
}),
)
const PluginManifest = Schema.Struct({
name: Schema.optionalKey(Schema.String),
skills: Schema.optionalKey(Schema.Union([RelativeSkillPath, Schema.Array(Schema.Unknown)])),
})
const decodePluginManifest = Schema.decodeUnknownOption(Schema.fromJsonString(PluginManifest))
const decodeRelativeSkillPath = Schema.decodeUnknownOption(RelativeSkillPath)

const isAncestor = (ancestor: string, candidate: string): Effect.Effect<boolean, never, Path.Path> =>
Effect.gen(function* () {
Expand Down Expand Up @@ -53,30 +71,25 @@ const ancestorDirectories = (
})

const safeRelativePath = (value: unknown): string | null => {
if (typeof value !== 'string' || value.length === 0 || value.includes('\\') || value.includes('\0')) return null
const normalized = value.replace(/^\.\//, '')
if (
normalized.length === 0 ||
normalized.startsWith('/') ||
/^[A-Za-z]:\//.test(normalized) ||
normalized.split('/').includes('..')
)
return null
return normalized
const decoded = Option.getOrUndefined(decodeRelativeSkillPath(value))
return decoded === undefined ? null : decoded.replace(/^\.\//, '')
}

const readManifest = (
root: string,
): Effect.Effect<{ path: string; value: unknown } | null, never, FileSystem.FileSystem | Path.Path> =>
): Effect.Effect<
{ path: string; value: typeof PluginManifest.Type | null } | null,
never,
FileSystem.FileSystem | Path.Path
> =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
for (const name of ['plugin.json', '.grok-plugin/plugin.json', '.claude-plugin/plugin.json']) {
const manifestPath = path.join(root, name)
const contents = yield* fs.readFileString(manifestPath).pipe(Effect.orElseSucceed(() => null))
if (contents === null) continue
const value = yield* Effect.try(() => JSON.parse(contents)).pipe(Effect.orElseSucceed(() => null))
return { path: manifestPath, value }
return { path: manifestPath, value: Option.getOrNull(decodePluginManifest(contents)) }
}
return null
})
Expand Down Expand Up @@ -122,15 +135,12 @@ export const discoverGrokPluginSkillRoots = Effect.fn('fold.grok_compatibility.d
seenPaths.add(normalized)
const manifest =
candidate === parent && parentManifest !== null ? parentManifest : yield* readManifest(candidate)
if (manifest !== null && !isRecord(manifest.value)) {
if (manifest !== null && manifest.value === null) {
diagnostics.push({ stage: 'manifest', code: 'manifest_parse_failed', path: manifest.path })
continue
}
const manifestValue = manifest === null || !isRecord(manifest.value) ? null : manifest.value
const name =
manifestValue !== null && typeof manifestValue.name === 'string'
? manifestValue.name
: path.basename(candidate)
const manifestValue = manifest?.value ?? null
const name = manifestValue?.name ?? path.basename(candidate)
if (name.length === 0 || seenNames.has(name)) continue
const declared =
manifestValue === null || manifestValue.skills === undefined
Expand Down
17 changes: 9 additions & 8 deletions packages/fold-agent/src/Compatibility/GrokSkills.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { homedir } from 'node:os'

import { SkillNotFoundError, type Skill, type SkillMeta, type SkillSourceService } from '@humanlayer/fold-core'
import { Effect, FileSystem, Path } from 'effect'
import { Effect, FileSystem, Option, Path, Schema } from 'effect'
import { parse as parseYaml } from 'yaml'

export type GrokSkillOptions = {
Expand All @@ -21,8 +21,11 @@ const exists = (path: string): Effect.Effect<boolean, never, FileSystem.FileSyst
return yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false))
})

const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value)
const SkillFrontmatter = Schema.Struct({
name: Schema.optionalKey(Schema.String),
description: Schema.optionalKey(Schema.String),
})
const decodeSkillFrontmatter = Schema.decodeUnknownOption(SkillFrontmatter)

const isAncestor = (ancestor: string, candidate: string): Effect.Effect<boolean, never, Path.Path> =>
Effect.gen(function* () {
Expand Down Expand Up @@ -76,14 +79,12 @@ const loadSkill = (
content = normalized.slice(end + 4).trim()
}
}
const record = isRecord(parsed) ? parsed : {}
const record = Option.getOrUndefined(decodeSkillFrontmatter(parsed))
const rawName =
typeof record.name === 'string' && record.name.length > 0
? record.name
: path.basename(directory)
record?.name !== undefined && record.name.length > 0 ? record.name : path.basename(directory)
const name = namespace === undefined ? rawName : `${namespace}:${rawName}`
const description =
typeof record.description === 'string' && record.description.trim().length > 0
record?.description !== undefined && record.description.trim().length > 0
? record.description.trim()
: content
.split(/\n\s*\n/)[0]
Expand Down
17 changes: 12 additions & 5 deletions packages/fold-agent/src/Session/SessionLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ export type SessionSummary = SessionLogRef & {

const decodeSessionId = Schema.decodeUnknownOption(SessionId)

const SessionMetaFields = Schema.Struct({
mode: Schema.optionalKey(Schema.String),
profile: Schema.optionalKey(Schema.String),
rpi: Schema.optionalKey(Schema.Boolean),
})
const decodeSessionMetaFields = Schema.decodeUnknownOption(SessionMetaFields)

/**
* The project slug for one working directory (pi-style escaped cwd): every non-alphanumeric run
* becomes a single dash, so `/Users/kyle/projects/fold` -> `Users-kyle-projects-fold`. Deterministic,
Expand Down Expand Up @@ -234,7 +241,7 @@ const isFinishedAssistantMessage = (entry: LogEntry): entry is FinishedAssistant

const userMessageText = (entry: Extract<LogEntry, { readonly _tag: 'user-message' }>): string => {
const content = entry.message.content
return typeof content === 'string'
return Predicate.isString(content)
? content
: content.flatMap((part) => (part.type === 'text' ? [part.text] : [])).join('')
}
Expand Down Expand Up @@ -278,7 +285,7 @@ const sessionSummary = (ref: SessionLogRef, entries: ReadonlyArray<LogEntry>): S
const modelEntry = rootEntries.findLast(carriesModel)
const model = modelEntry?.model ?? null
const latestUsage = rootEntries.findLast(isFinishedAssistantMessage)
const meta = started?.meta ?? {}
const meta = Option.getOrUndefined(decodeSessionMetaFields(started?.meta ?? {}))
const lastFinished = rootEntries.findLast(isAgentFinished)
const latestRootEntry = rootEntries.findLast((entry) => !isSessionTitle(entry))
const status = computeStatus(lastFinished, latestRootEntry)
Expand All @@ -292,9 +299,9 @@ const sessionSummary = (ref: SessionLogRef, entries: ReadonlyArray<LogEntry>): S
modelId: model?.modelId ?? null,
model,
contextTokens: latestUsage !== undefined ? usageInputTotal(latestUsage.finish.usage) : null,
mode: typeof meta.mode === 'string' ? meta.mode : null,
rpi: meta.rpi === true,
profile: typeof meta.profile === 'string' ? meta.profile : null,
mode: meta?.mode ?? null,
rpi: meta?.rpi === true,
profile: meta?.profile ?? null,
}
}

Expand Down
2 changes: 1 addition & 1 deletion packages/fold-agent/src/Session/TitleGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const isMessageEntry = (entry: LogEntry): entry is MessageEntry =>
Predicate.isTagged(entry, 'user-message') || Predicate.isTagged(entry, 'assistant-message')

const extractMessageText = (entry: MessageEntry): string =>
typeof entry.message.content === 'string'
Predicate.isString(entry.message.content)
? entry.message.content
: entry.message.content.flatMap((part) => (part.type === 'text' ? [part.text] : [])).join('')

Expand Down
Loading
Loading