Extension Loading (TypeScript/JavaScript Modules)
This document covers how the coding agent discovers and loads extension modules (.ts/.js) at startup.
It does not cover gemini-extension.json manifest extensions (documented separately).
What this subsystem does
Extension loading builds a list of module entry files, imports each module with Bun, executes its factory, and returns:
- loaded extension definitions
- per-path load errors (without aborting the whole load)
- a shared extension runtime object used later by
ExtensionRunner
Primary implementation files
src/extensibility/extensions/loader.ts— path discovery + import/executionsrc/extensibility/extensions/index.ts— public exportssrc/extensibility/extensions/runner.ts— runtime/event execution after loadsrc/discovery/builtin.ts— native auto-discovery provider for extension modulessrc/config/settings.ts— loads mergedextensions/disabledExtensionssettings
Inputs to extension loading
1) Auto-discovered native extension modules
discoverAndLoadExtensions() first asks discovery providers for extension-module capability items, then keeps only provider native items.
Effective native locations:
- Project:
<cwd>/.pisces/extensions - User:
~/.pisces/agent/extensions
Path roots come from the native provider (SOURCE_PATHS.native).
Notes:
- Native auto-discovery is currently
.piscesbased. - Legacy
.piis still accepted inpackage.jsonmanifest keys (pi.extensions), but not as a native root here.
2) Explicitly configured paths
After auto-discovery, configured paths are appended and resolved.
Configured path sources in the main session startup path (sdk.ts):
- CLI-provided paths (
--extension/-e, and--hookis also treated as an extension path) - Settings
extensionsarray (merged global + project settings)
Global settings file:
~/.pisces/agent/config.yml(or custom agent dir viaPI_CODING_AGENT_DIR)
Project settings file:
<cwd>/.pisces/settings.json
Examples:
# ~/.pisces/agent/config.yml
extensions:
- ~/my-exts/safety.ts
- ./local/ext-pack{
"extensions": ["./.pisces/extensions/my-extra"]
}Enable/disable controls
Disable discovery
- CLI:
--no-extensions - SDK option:
disableExtensionDiscovery
Behavior split:
- SDK: when
disableExtensionDiscovery=true, it still loadsadditionalExtensionPathsvialoadExtensions(). - CLI path building (
main.ts) currently clears CLI extension paths when--no-extensionsis set, so explicit-e/--hookare not forwarded in that mode.
Disable specific extension modules
disabledExtensions setting filters by extension id format:
extension-module:<derivedName>
derivedName is based on entry path (getExtensionNameFromPath), for example:
/x/foo.ts->foo/x/bar/index.ts->bar
Example:
disabledExtensions:
- extension-module:fooPath and entry resolution
Path normalization
For configured paths:
- Normalize unicode spaces
- Expand
~ - If relative, resolve against current
cwd
If configured path is a file
It is used directly as a module entry candidate.
If configured path is a directory
Resolution order:
package.jsonin that directory withomp.extensions(or legacypi.extensions) -> use declared entriesindex.tsindex.js- Otherwise scan one level for extension entries:
- direct
*.ts/*.js - subdir
index.ts/index.js - subdir
package.jsonwithomp.extensions/pi.extensions
- direct
Rules and constraints:
- no recursive discovery beyond one subdirectory level
- declared
extensionsmanifest entries are resolved relative to that package directory - declared entries are included only if file exists/access is allowed
- in
*/index.{ts,js}pairs, TypeScript is preferred over JavaScript - symlinks are treated as eligible files/directories
Ignore behavior differs by source
- Native auto-discovery (
discoverExtensionModulePathsin discovery helpers) uses native glob withgitignore: trueandhidden: false. - Explicit configured directory scanning in
loader.tsusesreaddirrules and does not apply gitignore filtering.
Load order and precedence
discoverAndLoadExtensions() builds one ordered list and then calls loadExtensions().
Order:
- Native auto-discovered modules
- Explicit configured paths (in provided order)
In sdk.ts, configured order is:
- CLI additional paths
- Settings
extensions
De-duplication:
- absolute path based
- first seen path wins
- later duplicates are ignored
Implication: if the same module path is both auto-discovered and explicitly configured, it is loaded once at the first position (auto-discovered stage).
Module import and factory contract
Each candidate path is loaded with dynamic import:
await import(resolvedPath)- factory is
module.default ?? module - factory must be a function (
ExtensionFactory)
If export is not a function, that path fails with a structured error and loading continues.
Failure handling and isolation
During loading
Per extension path, failures are captured as { path, error } and do not stop other paths from loading.
Common cases:
- import failure / missing file
- invalid factory export (non-function)
- exception thrown while executing factory
Runtime isolation model
- Extensions are not sandboxed (same process/runtime).
- They share one
EventBusand oneExtensionRuntimeinstance. - During load, runtime action methods intentionally throw
ExtensionRuntimeNotInitializedError; action wiring happens later inExtensionRunner.initialize().
After loading
When events run through ExtensionRunner, handler exceptions are caught and emitted as extension errors instead of crashing the runner loop.
Minimal user/project layout examples
User-level
~/.pisces/agent/
config.yml
extensions/
guardrails.ts
audit/
index.tsProject-level
<repo>/
.pisces/
settings.json
extensions/
checks/
package.json
lint-gates.tschecks/package.json:
{
"omp": {
"extensions": ["./src/check-a.ts", "./src/check-b.js"]
}
}Legacy manifest key still accepted:
{
"pi": {
"extensions": ["./index.ts"]
}
}