loreli 0.0.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +710 -97
- package/bin/loreli.js +89 -0
- package/package.json +77 -14
- package/packages/README.md +101 -0
- package/packages/action/README.md +98 -0
- package/packages/action/prompts/action.md +172 -0
- package/packages/action/src/index.js +684 -0
- package/packages/agent/README.md +606 -0
- package/packages/agent/src/backends/claude.js +387 -0
- package/packages/agent/src/backends/codex.js +351 -0
- package/packages/agent/src/backends/cursor.js +371 -0
- package/packages/agent/src/backends/index.js +486 -0
- package/packages/agent/src/base.js +138 -0
- package/packages/agent/src/cli.js +275 -0
- package/packages/agent/src/discover.js +396 -0
- package/packages/agent/src/factory.js +124 -0
- package/packages/agent/src/index.js +12 -0
- package/packages/agent/src/models.js +159 -0
- package/packages/agent/src/output.js +62 -0
- package/packages/agent/src/session.js +162 -0
- package/packages/agent/src/trace.js +186 -0
- package/packages/classify/README.md +136 -0
- package/packages/classify/prompts/blocker.md +12 -0
- package/packages/classify/prompts/feedback.md +14 -0
- package/packages/classify/prompts/pane-state.md +20 -0
- package/packages/classify/src/index.js +81 -0
- package/packages/config/README.md +898 -0
- package/packages/config/src/defaults.js +145 -0
- package/packages/config/src/index.js +223 -0
- package/packages/config/src/schema.js +291 -0
- package/packages/config/src/validate.js +160 -0
- package/packages/context/README.md +165 -0
- package/packages/context/src/index.js +198 -0
- package/packages/hub/README.md +338 -0
- package/packages/hub/src/base.js +154 -0
- package/packages/hub/src/github.js +1597 -0
- package/packages/hub/src/index.js +79 -0
- package/packages/hub/src/labels.js +48 -0
- package/packages/identity/README.md +288 -0
- package/packages/identity/src/index.js +620 -0
- package/packages/identity/src/themes/avatar.js +217 -0
- package/packages/identity/src/themes/digimon.js +217 -0
- package/packages/identity/src/themes/dragonball.js +217 -0
- package/packages/identity/src/themes/lotr.js +217 -0
- package/packages/identity/src/themes/marvel.js +217 -0
- package/packages/identity/src/themes/pokemon.js +217 -0
- package/packages/identity/src/themes/starwars.js +217 -0
- package/packages/identity/src/themes/transformers.js +217 -0
- package/packages/identity/src/themes/zelda.js +217 -0
- package/packages/knowledge/README.md +217 -0
- package/packages/knowledge/src/index.js +243 -0
- package/packages/log/README.md +93 -0
- package/packages/log/src/index.js +252 -0
- package/packages/marker/README.md +200 -0
- package/packages/marker/src/index.js +184 -0
- package/packages/mcp/README.md +323 -0
- package/packages/mcp/instructions.md +126 -0
- package/packages/mcp/scaffolding/.agents/skills/loreli-context/SKILL.md +89 -0
- package/packages/mcp/scaffolding/ISSUE_TEMPLATE/config.yml +2 -0
- package/packages/mcp/scaffolding/ISSUE_TEMPLATE/loreli.yml +83 -0
- package/packages/mcp/scaffolding/loreli.yml +491 -0
- package/packages/mcp/scaffolding/mcp-configs/.codex/config.toml +4 -0
- package/packages/mcp/scaffolding/mcp-configs/.cursor/mcp.json +14 -0
- package/packages/mcp/scaffolding/mcp-configs/.mcp.json +14 -0
- package/packages/mcp/scaffolding/pull-request.md +23 -0
- package/packages/mcp/src/index.js +600 -0
- package/packages/mcp/src/tools/agent-context.js +44 -0
- package/packages/mcp/src/tools/agents.js +450 -0
- package/packages/mcp/src/tools/context.js +200 -0
- package/packages/mcp/src/tools/github.js +1163 -0
- package/packages/mcp/src/tools/hitl.js +162 -0
- package/packages/mcp/src/tools/index.js +18 -0
- package/packages/mcp/src/tools/refactor.js +227 -0
- package/packages/mcp/src/tools/repo.js +44 -0
- package/packages/mcp/src/tools/start.js +904 -0
- package/packages/mcp/src/tools/status.js +149 -0
- package/packages/mcp/src/tools/work.js +134 -0
- package/packages/orchestrator/README.md +192 -0
- package/packages/orchestrator/src/index.js +1492 -0
- package/packages/planner/README.md +251 -0
- package/packages/planner/prompts/plan-reviewer.md +109 -0
- package/packages/planner/prompts/planner.md +191 -0
- package/packages/planner/prompts/tiebreaker-reviewer.md +71 -0
- package/packages/planner/src/index.js +1381 -0
- package/packages/review/README.md +129 -0
- package/packages/review/prompts/reviewer.md +158 -0
- package/packages/review/src/index.js +1403 -0
- package/packages/risk/README.md +178 -0
- package/packages/risk/prompts/risk.md +272 -0
- package/packages/risk/src/index.js +439 -0
- package/packages/session/README.md +165 -0
- package/packages/session/src/index.js +215 -0
- package/packages/test-utils/README.md +96 -0
- package/packages/test-utils/src/index.js +354 -0
- package/packages/tmux/README.md +261 -0
- package/packages/tmux/src/index.js +501 -0
- package/packages/workflow/README.md +317 -0
- package/packages/workflow/prompts/preamble.md +14 -0
- package/packages/workflow/src/index.js +660 -0
- package/packages/workflow/src/proof-of-life.js +74 -0
- package/packages/workspace/README.md +143 -0
- package/packages/workspace/src/index.js +1127 -0
- package/index.js +0 -8
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { prepare, pathFor, mirror, create as createWorktree } from 'loreli/workspace';
|
|
2
|
+
import { vendor, pick } from 'loreli/identity';
|
|
3
|
+
import { resolve as resolveModel } from './models.js';
|
|
4
|
+
import { logger } from 'loreli/log';
|
|
5
|
+
|
|
6
|
+
const log = logger('factory');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Agent factory that centralizes the creation pipeline:
|
|
10
|
+
* discover → acquire identity → create cwd → select backend → instantiate.
|
|
11
|
+
*
|
|
12
|
+
* Both the orchestrator's `enlist()` and `rework()` paths were duplicating
|
|
13
|
+
* this sequence with subtle inconsistencies (e.g. rework used `defaultBackend()`
|
|
14
|
+
* instead of `forProvider()`). This class is the single source of truth.
|
|
15
|
+
*
|
|
16
|
+
* The factory **creates** agents but does not **spawn** them. Spawning
|
|
17
|
+
* and registration is the caller's responsibility (e.g. the orchestrator's
|
|
18
|
+
* `spawn()` method handles process start, map registration, and rollback).
|
|
19
|
+
*
|
|
20
|
+
* The factory composes the backend registry and identity registry — it does
|
|
21
|
+
* not own them. The orchestrator (or any other caller) injects them.
|
|
22
|
+
*/
|
|
23
|
+
export class Factory {
|
|
24
|
+
/**
|
|
25
|
+
* @param {object} opts
|
|
26
|
+
* @param {import('./backends/index.js').BackendRegistry} opts.backends - Backend registry.
|
|
27
|
+
* @param {import('loreli/identity').Registry} opts.identities - Identity registry.
|
|
28
|
+
* @param {object} [opts.config] - Config instance for model resolution.
|
|
29
|
+
*/
|
|
30
|
+
constructor({ backends, identities, config }) {
|
|
31
|
+
/** @type {import('./backends/index.js').BackendRegistry} */
|
|
32
|
+
this.backends = backends;
|
|
33
|
+
|
|
34
|
+
/** @type {import('loreli/identity').Registry} */
|
|
35
|
+
this.identities = identities;
|
|
36
|
+
|
|
37
|
+
/** @type {object|undefined} */
|
|
38
|
+
this.config = config;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Create an agent for the given provider and role.
|
|
43
|
+
*
|
|
44
|
+
* Pipeline:
|
|
45
|
+
* 1. Discover available backends (idempotent)
|
|
46
|
+
* 2. Acquire a themed identity from the identity registry
|
|
47
|
+
* 3. Create the agent's working directory
|
|
48
|
+
* 4. Select the best backend via `forProvider()`
|
|
49
|
+
* 5. Instantiate the backend class
|
|
50
|
+
*
|
|
51
|
+
* The returned agent is in `idle` state — call `agent.spawn()` (or
|
|
52
|
+
* the orchestrator's `spawn()` for registration+rollback) to start it.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} provider - AI provider ('anthropic', 'openai').
|
|
55
|
+
* @param {string} role - Agent role ('action', 'reviewer', 'planner').
|
|
56
|
+
* @param {object} [opts] - Additional options.
|
|
57
|
+
* @param {string} [opts.theme='transformers'] - Theme for identity.
|
|
58
|
+
* @param {string} [opts.model='balanced'] - Model alias or exact string.
|
|
59
|
+
* @param {object} [opts.config] - Config instance override (falls back to factory-level config).
|
|
60
|
+
* @param {string} [opts.cwd] - Override working directory (default: ~/.loreli/workspaces/loreli-{name}).
|
|
61
|
+
* @returns {Promise<object>} The unspawned agent instance (state: idle).
|
|
62
|
+
*/
|
|
63
|
+
async create(provider, role, opts = {}) {
|
|
64
|
+
const theme = opts.theme ?? pick('transformers');
|
|
65
|
+
const model = opts.model ?? 'balanced';
|
|
66
|
+
|
|
67
|
+
await this.backends.discover();
|
|
68
|
+
|
|
69
|
+
const backendName = this.backends.forProvider(provider);
|
|
70
|
+
|
|
71
|
+
// Resolve the model alias to a concrete identifier so the identity
|
|
72
|
+
// carries the real model name for signatures and labels. Without
|
|
73
|
+
// this, identities created via Factory.create() (the enlist() path)
|
|
74
|
+
// would have an empty model while add_agent identities had the
|
|
75
|
+
// resolved name — causing "unknown" in reviewer stamps.
|
|
76
|
+
const config = opts.config ?? this.config;
|
|
77
|
+
const resolved = resolveModel(model, backendName, vendor(provider), config, this.backends.models);
|
|
78
|
+
|
|
79
|
+
const identity = this.identities.acquire(theme, provider, resolved, opts.taken);
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
// Update context.agent before prepare() and backend construction
|
|
83
|
+
// so the Codex -c flags and .mcp.json env vars have the real name
|
|
84
|
+
// instead of null. Context is mutated in-place intentionally —
|
|
85
|
+
// callers (e.g. enlist) pass the same reference and read it back.
|
|
86
|
+
if (opts.context) {
|
|
87
|
+
opts.context.agent = identity.name;
|
|
88
|
+
|
|
89
|
+
if (!opts.context.denied) {
|
|
90
|
+
opts.context.denied = config?.get?.('agents.disallowedTools') ?? [];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let cwd = opts.cwd ?? pathFor(identity.name);
|
|
95
|
+
|
|
96
|
+
// Collect scaffold descriptors from all registered backends so
|
|
97
|
+
// workspace.prepare() and workspace.create() write configs, hooks,
|
|
98
|
+
// and files generically — no backend-specific knowledge needed.
|
|
99
|
+
const descriptors = this.backends.scaffoldAll(opts.context);
|
|
100
|
+
|
|
101
|
+
// When we have full orchestration context (repo + home), create a
|
|
102
|
+
// git worktree instead of an empty directory. This gives the agent
|
|
103
|
+
// a fully checked-out repo with zero network overhead per agent —
|
|
104
|
+
// all from a shared bare mirror.
|
|
105
|
+
if (opts.context?.repo && opts.context?.home && !opts.cwd) {
|
|
106
|
+
const url = `https://github.com/${opts.context.repo}.git`;
|
|
107
|
+
const bare = await mirror(url, { home: opts.context.home, token: opts.context.token });
|
|
108
|
+
cwd = await createWorktree(bare, 'HEAD', identity.name, undefined, opts.context, descriptors);
|
|
109
|
+
log.info(`worktree ready at ${cwd} from mirror of ${opts.context.repo}`);
|
|
110
|
+
} else {
|
|
111
|
+
await prepare(cwd, opts.context, descriptors);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const agentOpts = { identity, role, cwd, model, config };
|
|
115
|
+
if (opts.context) agentOpts.context = opts.context;
|
|
116
|
+
|
|
117
|
+
log.info(`created ${identity.name} (${role}) via ${backendName}`);
|
|
118
|
+
return this.backends.create(backendName, agentOpts);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
this.identities.release(identity);
|
|
121
|
+
throw err;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { Agent } from './base.js';
|
|
2
|
+
export { CliAgent } from './cli.js';
|
|
3
|
+
export { Session, STATES, TRANSITIONS } from './session.js';
|
|
4
|
+
export { BackendRegistry } from './backends/index.js';
|
|
5
|
+
export { Factory } from './factory.js';
|
|
6
|
+
export { ClaudeBackend } from './backends/claude.js';
|
|
7
|
+
export { CodexBackend } from './backends/codex.js';
|
|
8
|
+
export { CursorBackend } from './backends/cursor.js';
|
|
9
|
+
export * as models from './models.js';
|
|
10
|
+
export * as output from './output.js';
|
|
11
|
+
export * as trace from './trace.js';
|
|
12
|
+
export { prepare } from 'loreli/workspace';
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { defaults } from 'loreli/config';
|
|
2
|
+
import { validate as validateModel } from './discover.js';
|
|
3
|
+
import { logger } from 'loreli/log';
|
|
4
|
+
|
|
5
|
+
const log = logger('models');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resolve a model alias to a concrete model identifier.
|
|
9
|
+
*
|
|
10
|
+
* Resolution order:
|
|
11
|
+
* 1. Config layer: `config.get('backends.{backend}.models.{alias}.{provider}')`
|
|
12
|
+
* 2. Discovery layer: runtime-discovered models classified into tiers
|
|
13
|
+
* 3. Built-in defaults: `defaults.backends[backend].models[alias][provider]`
|
|
14
|
+
* 4. Pass-through: return the alias string unchanged (exact model IDs)
|
|
15
|
+
*
|
|
16
|
+
* When discovery data is available, the resolved model is validated
|
|
17
|
+
* against the known model list. Invalid models trigger a warning and
|
|
18
|
+
* fall back to the backend's default discovered model.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} alias - Alias ('fast', 'balanced', 'powerful') or exact model string.
|
|
21
|
+
* @param {string} backend - Backend name ('claude', 'codex', 'cursor').
|
|
22
|
+
* @param {string} provider - AI provider ('openai' | 'anthropic').
|
|
23
|
+
* @param {object} [config] - Config instance with a `get()` method.
|
|
24
|
+
* @param {Map} [discovered] - Discovery cache from BackendRegistry.
|
|
25
|
+
* @returns {string} Resolved model identifier.
|
|
26
|
+
*/
|
|
27
|
+
export function resolve(alias, backend, provider, config, discovered) {
|
|
28
|
+
const fromConfig = config?.get?.(`backends.${backend}.models.${alias}.${provider}`);
|
|
29
|
+
if (fromConfig) return fromConfig;
|
|
30
|
+
|
|
31
|
+
const fromDiscovery = discovered?.get(backend)?.tiers?.[alias]?.[provider];
|
|
32
|
+
if (fromDiscovery) return fromDiscovery;
|
|
33
|
+
|
|
34
|
+
const fallback = defaults.backends?.[backend]?.models?.[alias]?.[provider];
|
|
35
|
+
if (fallback) {
|
|
36
|
+
if (discovered && !validateModel(fallback, backend, discovered)) {
|
|
37
|
+
log.warn(`model "${fallback}" (${alias}/${backend}/${provider}) not found in discovered models — using backend default`);
|
|
38
|
+
const def = discovered.get(backend)?.models?.find(function isDefault(m) { return m.marker === 'default'; });
|
|
39
|
+
if (def) return def.id;
|
|
40
|
+
}
|
|
41
|
+
return fallback;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Pass-through for exact model strings
|
|
45
|
+
return alias;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Env var prefixes relevant to each backend. Used to inherit
|
|
50
|
+
* process.env vars so launcher scripts in tmux get the right
|
|
51
|
+
* proxy URLs, auth tokens, and feature flags.
|
|
52
|
+
*
|
|
53
|
+
* @type {Record<string, string[]>}
|
|
54
|
+
*/
|
|
55
|
+
const PREFIXES = {
|
|
56
|
+
claude: ['ANTHROPIC_', 'CLAUDE_'],
|
|
57
|
+
codex: ['OPENAI_', 'CODEX_'],
|
|
58
|
+
cursor: ['ANTHROPIC_', 'OPENAI_', 'CLAUDE_', 'CURSOR_']
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Collect process.env vars matching a backend's known prefixes.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} backend - Backend name.
|
|
65
|
+
* @returns {object} Matching env vars from process.env.
|
|
66
|
+
*/
|
|
67
|
+
function inherit(backend) {
|
|
68
|
+
const prefixes = PREFIXES[backend] ?? [];
|
|
69
|
+
if (!prefixes.length) return {};
|
|
70
|
+
|
|
71
|
+
const result = {};
|
|
72
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
73
|
+
if (value && prefixes.some(function match(p) { return key.startsWith(p); })) {
|
|
74
|
+
result[key] = value;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Retrieve backend-specific environment variables.
|
|
82
|
+
*
|
|
83
|
+
* Merges two layers:
|
|
84
|
+
* 1. Inherited: process.env vars matching the backend's known prefixes
|
|
85
|
+
* (e.g. ANTHROPIC_BASE_URL, CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS)
|
|
86
|
+
* 2. Config overrides: `backends.{backend}.env` from loreli.yml
|
|
87
|
+
*
|
|
88
|
+
* Config values take precedence over process.env when keys collide.
|
|
89
|
+
* Returns undefined when no vars are collected from either layer.
|
|
90
|
+
*
|
|
91
|
+
* @param {string} backend - Backend name ('claude', 'codex', 'cursor').
|
|
92
|
+
* @param {object} [config] - Config instance with a `get()` method.
|
|
93
|
+
* @returns {object|undefined} Merged environment variables, or undefined if empty.
|
|
94
|
+
*/
|
|
95
|
+
export function env(backend, config) {
|
|
96
|
+
const inherited = inherit(backend);
|
|
97
|
+
|
|
98
|
+
const configured = config?.get?.(`backends.${backend}.env`);
|
|
99
|
+
const overrides = (configured && typeof configured === 'object') ? configured : {};
|
|
100
|
+
|
|
101
|
+
// Config overrides process.env
|
|
102
|
+
const merged = { ...inherited, ...overrides };
|
|
103
|
+
if (!Object.keys(merged).length) return undefined;
|
|
104
|
+
return merged;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Format an env object into shell export lines for launcher scripts.
|
|
109
|
+
*
|
|
110
|
+
* Returns an empty string when no env is configured, keeping the
|
|
111
|
+
* launcher script clean. Values are single-quoted to prevent expansion.
|
|
112
|
+
*
|
|
113
|
+
* @param {object|undefined} vars - Key/value environment map.
|
|
114
|
+
* @returns {string} Shell export statements (with trailing newline), or empty string.
|
|
115
|
+
*/
|
|
116
|
+
export function format(vars) {
|
|
117
|
+
if (!vars || !Object.keys(vars).length) return '';
|
|
118
|
+
return Object.entries(vars)
|
|
119
|
+
.map(function line([k, v]) { return `export ${k}='${v}'`; })
|
|
120
|
+
.join('\n') + '\n';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Format an env object as a single-line shell export string.
|
|
125
|
+
*
|
|
126
|
+
* Designed for inline use with tmux commands — no newlines, exports
|
|
127
|
+
* joined with ` && `. Values are single-quoted to prevent expansion.
|
|
128
|
+
*
|
|
129
|
+
* @param {object|undefined} vars - Key/value environment map.
|
|
130
|
+
* @returns {string} Inline export string, or empty string when no vars.
|
|
131
|
+
*/
|
|
132
|
+
export function inline(vars) {
|
|
133
|
+
if (!vars || !Object.keys(vars).length) return '';
|
|
134
|
+
return Object.entries(vars)
|
|
135
|
+
.map(function line([k, v]) { return `export ${k}='${v}'`; })
|
|
136
|
+
.join(' && ');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* List all known aliases for a given backend.
|
|
141
|
+
*
|
|
142
|
+
* When a config instance is provided, reads alias keys from config's
|
|
143
|
+
* backend models. Otherwise falls back to built-in defaults.
|
|
144
|
+
*
|
|
145
|
+
* @param {string} backend - Backend name ('claude', 'codex', 'cursor').
|
|
146
|
+
* @param {object} [config] - Config instance with a `get()` method.
|
|
147
|
+
* @returns {string[]} Array of alias names.
|
|
148
|
+
*/
|
|
149
|
+
export function list(backend, config) {
|
|
150
|
+
if (!backend) throw new Error('models.list() requires a backend name');
|
|
151
|
+
|
|
152
|
+
const models = config?.get?.(`backends.${backend}.models`);
|
|
153
|
+
if (models && typeof models === 'object') return Object.keys(models);
|
|
154
|
+
|
|
155
|
+
const fallback = defaults.backends?.[backend]?.models;
|
|
156
|
+
if (fallback) return Object.keys(fallback);
|
|
157
|
+
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent output processing utilities.
|
|
3
|
+
*
|
|
4
|
+
* Handles cleaning, truncating, and formatting captured terminal output
|
|
5
|
+
* from CLI-backed agents. Used by the orchestrator's relay and any other
|
|
6
|
+
* consumer that reads agent pane output.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Regex to strip ANSI escape codes from terminal output.
|
|
11
|
+
* Matches both CSI sequences (e.g. `\x1b[31m`) and OSC sequences
|
|
12
|
+
* (e.g. `\x1b]0;title\x07`).
|
|
13
|
+
*
|
|
14
|
+
* @type {RegExp}
|
|
15
|
+
*/
|
|
16
|
+
export const ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07/g;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Default maximum characters for truncated output.
|
|
20
|
+
*
|
|
21
|
+
* @type {number}
|
|
22
|
+
*/
|
|
23
|
+
export const MAX_CHARS = 12000;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Strip ANSI escape codes from a string.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} text - Raw terminal output.
|
|
29
|
+
* @returns {string} Clean text without ANSI codes.
|
|
30
|
+
*/
|
|
31
|
+
export function strip(text) {
|
|
32
|
+
return text.replace(ANSI_RE, '');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Truncate text to a maximum length, keeping the tail.
|
|
37
|
+
*
|
|
38
|
+
* When the input exceeds `max` characters, the beginning is removed
|
|
39
|
+
* and a `...(truncated)` prefix is prepended. This preserves the most
|
|
40
|
+
* recent output which is typically the most relevant.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} text - Text to truncate.
|
|
43
|
+
* @param {number} [max=MAX_CHARS] - Maximum character count.
|
|
44
|
+
* @returns {string} Truncated text, or original if under the limit.
|
|
45
|
+
*/
|
|
46
|
+
export function truncate(text, max = MAX_CHARS) {
|
|
47
|
+
if (text.length <= max) return text;
|
|
48
|
+
return `...(truncated)\n${text.slice(-max)}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Clean agent output: strip ANSI codes, then truncate.
|
|
53
|
+
*
|
|
54
|
+
* Convenience function combining both operations in the standard order.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} text - Raw terminal output.
|
|
57
|
+
* @param {number} [max=MAX_CHARS] - Maximum character count.
|
|
58
|
+
* @returns {string} Cleaned and truncated text.
|
|
59
|
+
*/
|
|
60
|
+
export function clean(text, max = MAX_CHARS) {
|
|
61
|
+
return truncate(strip(text), max);
|
|
62
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Valid session states.
|
|
3
|
+
*
|
|
4
|
+
* @type {Set<string>}
|
|
5
|
+
*/
|
|
6
|
+
export const STATES = new Set([
|
|
7
|
+
'spawned', 'working', 'standby', 'reviewing', 'awaiting_hitl', 'dormant'
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Explicit state transition map.
|
|
12
|
+
*
|
|
13
|
+
* Each key is a current state, and the value is a Set of states
|
|
14
|
+
* it can transition to. Transitions not in this map are rejected.
|
|
15
|
+
*
|
|
16
|
+
* Uses an explicit graph instead of linear ordering because agent
|
|
17
|
+
* states have branching paths (e.g. working -> awaiting_hitl vs
|
|
18
|
+
* working -> dormant).
|
|
19
|
+
*
|
|
20
|
+
* @type {Record<string, Set<string>>}
|
|
21
|
+
*/
|
|
22
|
+
export const TRANSITIONS = {
|
|
23
|
+
spawned: new Set(['working', 'standby', 'dormant']),
|
|
24
|
+
working: new Set(['standby', 'reviewing', 'awaiting_hitl', 'dormant']),
|
|
25
|
+
standby: new Set(['working', 'reviewing', 'awaiting_hitl', 'dormant']),
|
|
26
|
+
reviewing: new Set(['working', 'standby', 'awaiting_hitl', 'dormant']),
|
|
27
|
+
awaiting_hitl: new Set(['working', 'standby', 'dormant']),
|
|
28
|
+
dormant: new Set([])
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Tracks an active agent's runtime state.
|
|
33
|
+
*
|
|
34
|
+
* Sessions are persisted to disk so agents survive orchestrator shutdown.
|
|
35
|
+
* State transitions are validated against the TRANSITIONS map to catch
|
|
36
|
+
* bugs where agents are reactivated without proper re-initialization.
|
|
37
|
+
*/
|
|
38
|
+
export class Session {
|
|
39
|
+
/**
|
|
40
|
+
* @param {object} opts
|
|
41
|
+
* @param {object} opts.identity - Agent identity object.
|
|
42
|
+
* @param {string} opts.role - Agent role ('planner' | 'action' | 'reviewer').
|
|
43
|
+
* @param {string} opts.backend - Backend name ('claude', 'codex', etc.).
|
|
44
|
+
* @param {string} [opts.paneId] - Tmux pane ID.
|
|
45
|
+
* @param {number} [opts.pid] - Process ID.
|
|
46
|
+
* @param {string} [opts.repo] - Target repository (owner/name).
|
|
47
|
+
*/
|
|
48
|
+
constructor({ identity, role, backend, paneId, pid, repo }) {
|
|
49
|
+
/** @type {object} Agent identity. */
|
|
50
|
+
this.identity = identity;
|
|
51
|
+
|
|
52
|
+
/** @type {string} Agent role. */
|
|
53
|
+
this.role = role;
|
|
54
|
+
|
|
55
|
+
/** @type {string} Backend name. */
|
|
56
|
+
this.backend = backend;
|
|
57
|
+
|
|
58
|
+
/** @type {string|null} Tmux pane ID. */
|
|
59
|
+
this.paneId = paneId ?? null;
|
|
60
|
+
|
|
61
|
+
/** @type {number|null} Process ID. */
|
|
62
|
+
this.pid = pid ?? null;
|
|
63
|
+
|
|
64
|
+
/** @type {string|null} Target repository. */
|
|
65
|
+
this.repo = repo ?? null;
|
|
66
|
+
|
|
67
|
+
/** @type {string} Current state. */
|
|
68
|
+
this.state = 'spawned';
|
|
69
|
+
|
|
70
|
+
/** @type {string} When the session started. */
|
|
71
|
+
this.startedAt = new Date().toISOString();
|
|
72
|
+
|
|
73
|
+
/** @type {string} Last activity timestamp. */
|
|
74
|
+
this.lastActivity = this.startedAt;
|
|
75
|
+
|
|
76
|
+
/** @type {number|null} Claimed issue number. */
|
|
77
|
+
this.claimedIssue = null;
|
|
78
|
+
|
|
79
|
+
/** @type {object|null} Dynamic task context from latest dispatch. */
|
|
80
|
+
this.task = null;
|
|
81
|
+
|
|
82
|
+
/** @type {string[]} Human reviewers assigned during HITL. */
|
|
83
|
+
this.reviewers = [];
|
|
84
|
+
|
|
85
|
+
/** @type {Array<{name: string, provider: string, timestamp: string}>} Agent approval records. */
|
|
86
|
+
this.agentApprovals = [];
|
|
87
|
+
|
|
88
|
+
/** @type {string|null} ISO timestamp when HITL was activated. */
|
|
89
|
+
this.hitlAt = null;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Token usage accumulator for the current task.
|
|
93
|
+
* Reset when a new task is assigned via dispatch.
|
|
94
|
+
*
|
|
95
|
+
* @type {{ input: number, output: number, model: string|null }}
|
|
96
|
+
*/
|
|
97
|
+
this.usage = { input: 0, output: 0, model: null };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Transition to a new state with validation.
|
|
102
|
+
*
|
|
103
|
+
* Validates the transition against the TRANSITIONS map. Invalid
|
|
104
|
+
* transitions throw an error to catch bugs where agents are moved
|
|
105
|
+
* to impossible states (e.g. dormant -> working without re-spawn).
|
|
106
|
+
*
|
|
107
|
+
* @param {string} next - New state (spawned | working | standby | reviewing | awaiting_hitl | dormant).
|
|
108
|
+
* @throws {Error} If the transition is invalid.
|
|
109
|
+
*/
|
|
110
|
+
transition(next) {
|
|
111
|
+
if (!STATES.has(next)) {
|
|
112
|
+
throw new Error(`Invalid state: "${next}". Valid: ${[...STATES].join(', ')}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const allowed = TRANSITIONS[this.state];
|
|
116
|
+
if (!allowed?.has(next)) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`Invalid transition: "${this.state}" -> "${next}". ` +
|
|
119
|
+
`Allowed from "${this.state}": ${allowed ? [...allowed].join(', ') || 'none (terminal)' : 'unknown'}`
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
this.state = next;
|
|
124
|
+
this.lastActivity = new Date().toISOString();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Check whether a transition to the given state is valid.
|
|
129
|
+
*
|
|
130
|
+
* @param {string} next - Target state to check.
|
|
131
|
+
* @returns {boolean} True if the transition is allowed.
|
|
132
|
+
*/
|
|
133
|
+
canTransition(next) {
|
|
134
|
+
return TRANSITIONS[this.state]?.has(next) ?? false;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Serialize the session to a plain object for JSON storage.
|
|
139
|
+
*
|
|
140
|
+
* @returns {object} Plain object representation.
|
|
141
|
+
*/
|
|
142
|
+
toJSON() {
|
|
143
|
+
return {
|
|
144
|
+
identity: this.identity,
|
|
145
|
+
role: this.role,
|
|
146
|
+
backend: this.backend,
|
|
147
|
+
paneId: this.paneId,
|
|
148
|
+
pid: this.pid,
|
|
149
|
+
repo: this.repo,
|
|
150
|
+
state: this.state,
|
|
151
|
+
startedAt: this.startedAt,
|
|
152
|
+
lastActivity: this.lastActivity,
|
|
153
|
+
claimedIssue: this.claimedIssue,
|
|
154
|
+
task: this.task,
|
|
155
|
+
reviewers: this.reviewers,
|
|
156
|
+
agentApprovals: this.agentApprovals,
|
|
157
|
+
hitlAt: this.hitlAt,
|
|
158
|
+
usage: this.usage
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
}
|