@hivemagnus/ardent 0.0.0-stage → 1.0.1

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.
Files changed (115) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +274 -2
  3. package/dist/a2a-client.d.ts +35 -0
  4. package/dist/a2a-client.js +172 -0
  5. package/dist/a2a-hermes-alias.d.ts +3 -0
  6. package/dist/a2a-hermes-alias.js +57 -0
  7. package/dist/a2a-idempotency.d.ts +13 -0
  8. package/dist/a2a-idempotency.js +86 -0
  9. package/dist/a2a-peers.d.ts +28 -0
  10. package/dist/a2a-peers.js +108 -0
  11. package/dist/a2a-push-inbox.d.ts +22 -0
  12. package/dist/a2a-push-inbox.js +84 -0
  13. package/dist/a2a-push.d.ts +27 -0
  14. package/dist/a2a-push.js +188 -0
  15. package/dist/a2a-server.d.ts +37 -0
  16. package/dist/a2a-server.js +186 -0
  17. package/dist/a2a-store.d.ts +10 -0
  18. package/dist/a2a-store.js +122 -0
  19. package/dist/a2a.d.ts +19 -0
  20. package/dist/a2a.js +466 -0
  21. package/dist/api.d.ts +6 -0
  22. package/dist/api.js +381 -0
  23. package/dist/artifacts.d.ts +15 -0
  24. package/dist/artifacts.js +41 -0
  25. package/dist/backend-events.d.ts +49 -0
  26. package/dist/backend-events.js +8 -0
  27. package/dist/channels.d.ts +52 -0
  28. package/dist/channels.js +184 -0
  29. package/dist/cli.d.ts +2 -0
  30. package/dist/cli.js +845 -0
  31. package/dist/codex-output-budget.d.ts +17 -0
  32. package/dist/codex-output-budget.js +31 -0
  33. package/dist/codex-output-budget.test.d.ts +1 -0
  34. package/dist/codex-output-budget.test.js +29 -0
  35. package/dist/codex.d.ts +82 -0
  36. package/dist/codex.js +431 -0
  37. package/dist/config.d.ts +171 -0
  38. package/dist/config.js +244 -0
  39. package/dist/context-checkpoints.d.ts +49 -0
  40. package/dist/context-checkpoints.js +248 -0
  41. package/dist/context.d.ts +16 -0
  42. package/dist/context.js +101 -0
  43. package/dist/gateway-lock.d.ts +18 -0
  44. package/dist/gateway-lock.js +104 -0
  45. package/dist/hermes-compat.d.ts +15 -0
  46. package/dist/hermes-compat.js +62 -0
  47. package/dist/identity.d.ts +15 -0
  48. package/dist/identity.js +52 -0
  49. package/dist/index.d.ts +9 -0
  50. package/dist/index.js +8 -0
  51. package/dist/job-engine.d.ts +222 -0
  52. package/dist/job-engine.js +497 -0
  53. package/dist/job-news-handlers.d.ts +54 -0
  54. package/dist/job-news-handlers.js +153 -0
  55. package/dist/job-ownership.d.ts +9 -0
  56. package/dist/job-ownership.js +17 -0
  57. package/dist/json-schema.d.ts +9 -0
  58. package/dist/json-schema.js +20 -0
  59. package/dist/llamacpp.d.ts +49 -0
  60. package/dist/llamacpp.js +216 -0
  61. package/dist/magnus-control.d.ts +3 -0
  62. package/dist/magnus-control.js +99 -0
  63. package/dist/magnus-node-dependency-impact.d.ts +1 -0
  64. package/dist/magnus-node-dependency-impact.js +126 -0
  65. package/dist/magnus-owner-reads.d.ts +4 -0
  66. package/dist/magnus-owner-reads.js +99 -0
  67. package/dist/magnus-readiness.d.ts +1 -0
  68. package/dist/magnus-readiness.js +108 -0
  69. package/dist/mattermost.d.ts +18 -0
  70. package/dist/mattermost.js +303 -0
  71. package/dist/memory-tools.d.ts +5 -0
  72. package/dist/memory-tools.js +49 -0
  73. package/dist/memory.d.ts +40 -0
  74. package/dist/memory.js +128 -0
  75. package/dist/model-policy.d.ts +64 -0
  76. package/dist/model-policy.js +56 -0
  77. package/dist/news.d.ts +10 -0
  78. package/dist/news.js +291 -0
  79. package/dist/owner-read.d.ts +32 -0
  80. package/dist/owner-read.js +130 -0
  81. package/dist/plugins.d.ts +74 -0
  82. package/dist/plugins.js +115 -0
  83. package/dist/research.d.ts +110 -0
  84. package/dist/research.js +164 -0
  85. package/dist/runtime.d.ts +33 -0
  86. package/dist/runtime.js +665 -0
  87. package/dist/skills.d.ts +13 -0
  88. package/dist/skills.js +87 -0
  89. package/dist/store.d.ts +142 -0
  90. package/dist/store.js +322 -0
  91. package/dist/team-artifacts.d.ts +138 -0
  92. package/dist/team-artifacts.js +241 -0
  93. package/dist/team-child.d.ts +21 -0
  94. package/dist/team-child.js +130 -0
  95. package/dist/team-contract.d.ts +116 -0
  96. package/dist/team-contract.js +84 -0
  97. package/dist/team-control.d.ts +8 -0
  98. package/dist/team-control.js +152 -0
  99. package/dist/telegram.d.ts +35 -0
  100. package/dist/telegram.js +284 -0
  101. package/dist/tools.d.ts +48 -0
  102. package/dist/tools.js +58 -0
  103. package/dist/version.d.ts +1 -0
  104. package/dist/version.js +5 -0
  105. package/model-policy.default.json +20 -0
  106. package/package.json +84 -4
  107. package/profiles/assistant/IDENTITY.md +9 -0
  108. package/profiles/assistant/SOUL.md +8 -0
  109. package/profiles/assistant/config.yaml +88 -0
  110. package/profiles/assistant/peers.yaml +12 -0
  111. package/profiles/assistant/skills/grounded-answers/SKILL.md +18 -0
  112. package/protocol/LICENSE-A2A.txt +202 -0
  113. package/protocol/README.md +12 -0
  114. package/protocol/a2a.proto +811 -0
  115. package/protocol/lock.json +9 -0
package/dist/cli.js ADDED
@@ -0,0 +1,845 @@
1
+ #!/usr/bin/env node
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { readFile, readdir, lstat } from 'node:fs/promises';
4
+ import { isAbsolute, join } from 'node:path';
5
+ import { createInterface } from 'node:readline/promises';
6
+ import { stdin, stdout, stderr } from 'node:process';
7
+ import { parse } from 'dotenv';
8
+ import { z } from 'zod';
9
+ import { ardentHome, ConfigSchema, DEFAULT_PROFILE, initProfile, loadConfig, profilePath, redactedConfig, resolveReference, runtimeProblems } from './config.js';
10
+ import { ArdentRuntime } from './runtime.js';
11
+ import { TaskStore } from './store.js';
12
+ import { ardentOpenApi, createArdentApi } from './api.js';
13
+ import { serve } from '@hono/node-server';
14
+ import { listSkills, readSkill, setSkillEnabled } from './skills.js';
15
+ import { HiveMemClient } from './memory.js';
16
+ import { createA2ASurface, startA2AGrpc } from './a2a-server.js';
17
+ import { TelegramAdapter } from './telegram.js';
18
+ import { MattermostAdapter } from './mattermost.js';
19
+ import { ArdentA2AClient } from './a2a-client.js';
20
+ import { CodexAppServer, assertCodexProtocol, compactWithCodex } from './codex.js';
21
+ import { LlamaCppBackend } from './llamacpp.js';
22
+ import { toJsonSchema } from './json-schema.js';
23
+ import { hermesTeamCard } from './hermes-compat.js';
24
+ import Database from 'better-sqlite3';
25
+ import { JobEngine, validateJobDefinitions } from './job-engine.js';
26
+ import { composeOwnershipChecks } from './job-ownership.js';
27
+ import { BUILTIN_JOB_VALIDATION_REGISTRY, builtinJobHandlers } from './job-news-handlers.js';
28
+ import { acquireGatewayLock, createLockFile } from './gateway-lock.js';
29
+ import { ContextCheckpoints } from './context-checkpoints.js';
30
+ import { loadModelPolicy } from './model-policy.js';
31
+ import { loadPlugins, pluginContext, pluginJobHandlers, validationContext } from './plugins.js';
32
+ function args(argv) {
33
+ const words = [];
34
+ const options = new Map();
35
+ const valued = new Set(['profile', 'profile-path', 'api-port', 'template', 'prompt', 'prompt-file', 'idempotency-key', 'session-id', 'login-id', 'source', 'schedule-id', 'window-key', 'release-id', 'caller', 'context', 'expected-head', 'expected-policy-revision']);
36
+ for (let i = 0; i < argv.length; i++) {
37
+ const arg = argv[i];
38
+ if (!arg)
39
+ continue;
40
+ if (arg.startsWith('--')) {
41
+ const equals = arg.indexOf('=');
42
+ if (equals > 2) {
43
+ options.set(arg.slice(2, equals), arg.slice(equals + 1));
44
+ continue;
45
+ }
46
+ const name = arg.slice(2);
47
+ if (valued.has(name)) {
48
+ const value = argv[++i];
49
+ if (!value)
50
+ throw new Error(`Missing value for --${name}`);
51
+ options.set(name, value);
52
+ }
53
+ else
54
+ options.set(name, true);
55
+ }
56
+ else
57
+ words.push(arg);
58
+ }
59
+ return { words, options };
60
+ }
61
+ function value(options, name) {
62
+ const found = options.get(name);
63
+ return typeof found === 'string' ? found : undefined;
64
+ }
65
+ async function environment(dir) {
66
+ const env = { ...process.env };
67
+ try {
68
+ const data = parse(await readFile(join(dir, '.env')));
69
+ for (const [name, val] of Object.entries(data))
70
+ if (env[name] === undefined)
71
+ env[name] = val;
72
+ }
73
+ catch (error) {
74
+ if (!(error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT'))
75
+ throw error;
76
+ }
77
+ return env;
78
+ }
79
+ function print(value) { stdout.write(`${JSON.stringify(value, null, 2)}\n`); }
80
+ async function assertNoGateway(dir) {
81
+ const lock = await readFile(join(dir, 'state', 'gateway.lock'), 'utf8').catch((error) => {
82
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT')
83
+ return undefined;
84
+ throw error;
85
+ });
86
+ if (lock === undefined)
87
+ return;
88
+ const pid = Number(lock.trim());
89
+ if (!Number.isSafeInteger(pid) || pid <= 0)
90
+ throw new Error('Profile gateway lock is malformed; inspect state/gateway.lock');
91
+ try {
92
+ process.kill(pid, 0);
93
+ throw new Error(`Profile is owned by gateway PID ${pid}; use its management API`);
94
+ }
95
+ catch (error) {
96
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ESRCH')
97
+ throw new Error('Profile gateway lock is stale; inspect state/gateway.lock before clearing it');
98
+ throw error;
99
+ }
100
+ }
101
+ const USAGE = `Usage: ardent [--profile NAME | --profile-path DIR] <command>
102
+
103
+ Commands:
104
+ init --non-interactive [--api-port N] Create a profile from the bundled template
105
+ profile list | profile show List profiles / show the selected profile
106
+ config validate | show | schema Inspect configuration
107
+ doctor Report unresolved runtime configuration
108
+ providers list | providers test Inspect or probe the model backend
109
+ auth codex status | login | cancel Manage the Codex account sign-in
110
+ skills list | show | enable | disable Manage profile skills
111
+ memory search <query> Search HiveMem (memory.provider: hivemem)
112
+ jobs definitions validate --source FILE Validate scheduled job definitions
113
+ jobs definitions status Show installed job definitions
114
+ context status | compact Inspect or compact a session context
115
+ admission status | close --release-id ID Inspect or close the admission fence
116
+ a2a discover <peer> | call <peer> Talk to a configured A2A peer
117
+ a2a team-card Print the Hermes team card (a2a.hermes_compat)
118
+ openapi export Print the management OpenAPI document
119
+ run --prompt TEXT | chat | report Run a task, chat, or run a scheduled report
120
+ sessions list | new | reset | show Manage local CLI sessions
121
+ tasks list | show | cancel | resume Inspect local tasks
122
+ gateway run Serve the management API, A2A, channels and jobs`;
123
+ async function main() {
124
+ const parsed = args(process.argv.slice(2));
125
+ const [command, subcommand, id] = parsed.words;
126
+ if (!command || command === 'help' || parsed.options.has('help')) {
127
+ stdout.write(`${USAGE}\n`);
128
+ return;
129
+ }
130
+ const profile = value(parsed.options, 'profile') ?? DEFAULT_PROFILE;
131
+ const dir = profilePath(profile, value(parsed.options, 'profile-path'));
132
+ if (command === 'init') {
133
+ if (!parsed.options.has('non-interactive'))
134
+ throw new Error('Use --non-interactive for reproducible profile initialization');
135
+ const apiPort = value(parsed.options, 'api-port');
136
+ const template = value(parsed.options, 'template');
137
+ await initProfile(dir, { profile, ...(apiPort ? { apiPort: Number(apiPort) } : {}), ...(template ? { template } : {}) });
138
+ print({ profile, path: dir, initialized: true });
139
+ return;
140
+ }
141
+ if (command === 'profile' && subcommand === 'list') {
142
+ const entries = await readdir(join(ardentHome(), 'profiles'), { withFileTypes: true }).catch(() => []);
143
+ print(entries.filter(entry => entry.isDirectory()).map(entry => ({ name: entry.name })));
144
+ return;
145
+ }
146
+ const loaded = await loadConfig(dir);
147
+ const env = await environment(dir);
148
+ const plugins = await loadPlugins(loaded.config, dir);
149
+ for (const plugin of plugins) {
150
+ const pluginCommand = command ? plugin.cliCommands?.[command] : undefined;
151
+ if (pluginCommand) {
152
+ await pluginCommand.run({ words: parsed.words.slice(1), options: parsed.options, profileDir: dir, config: loaded.config, env, print });
153
+ return;
154
+ }
155
+ }
156
+ if (command === 'context') {
157
+ const sessionId = value(parsed.options, 'session-id');
158
+ const caller = value(parsed.options, 'caller');
159
+ const context = value(parsed.options, 'context');
160
+ if (!sessionId || !z.string().guid().safeParse(sessionId).success || !caller || caller.length > 128 ||
161
+ !['direct', 'peer', 'scheduled'].includes(context ?? ''))
162
+ throw new Error('Use context status|compact --session-id UUID --caller NAME --context direct|peer|scheduled');
163
+ const parsedContext = z.enum(['direct', 'peer', 'scheduled']).parse(context);
164
+ const uid = process.getuid?.();
165
+ if (uid === undefined)
166
+ throw new Error('Context controls require the local profile owner');
167
+ for (const [path, kind] of [[dir, 'directory'], [join(dir, 'state'), 'directory'],
168
+ [join(dir, 'state', 'ardent.sqlite'), 'file']]) {
169
+ const stat = await lstat(path);
170
+ if (stat.isSymbolicLink() || stat.uid !== uid || (stat.mode & 0o077) !== 0 ||
171
+ (kind === 'directory' ? !stat.isDirectory() : !stat.isFile()))
172
+ throw new Error('Context controls require owner-only local profile state');
173
+ }
174
+ const store = new TaskStore(join(dir, 'state', 'ardent.sqlite'));
175
+ try {
176
+ const manager = new ContextCheckpoints(store, (route, prompt, signal) => compactWithCodex(loaded.config.providers.codex.command, dir, route.model, route.reasoning_effort, prompt, signal, env, loaded.config.providers.codex.auth_mode));
177
+ if (subcommand === 'status') {
178
+ print(manager.status(sessionId, caller, parsedContext));
179
+ return;
180
+ }
181
+ if (subcommand !== 'compact' || parsed.options.has('dry-run') === parsed.options.has('apply'))
182
+ throw new Error('Use context compact --dry-run or --apply');
183
+ const policy = await loadModelPolicy(dir, env);
184
+ if (!policy || !policy.policy.compaction.enabled)
185
+ throw new Error('An enabled shared model policy is required');
186
+ const expectedHead = value(parsed.options, 'expected-head');
187
+ const expectedRevision = value(parsed.options, 'expected-policy-revision');
188
+ if (parsed.options.has('apply') && (!expectedHead || !expectedRevision || !/^\d+$/.test(expectedRevision)))
189
+ throw new Error('Apply requires --expected-head UUID|none and --expected-policy-revision NUMBER');
190
+ const pseudo = { id: `preview:${randomUUID()}`, session_id: sessionId, caller,
191
+ context: parsedContext, prompt: '' };
192
+ const result = await manager.prepare(pseudo, `Manual context budget reservation. ${'x'.repeat(8000)}`, [], policy, AbortSignal.timeout(120_000), { apply: parsed.options.has('apply'), force: true,
193
+ ...(expectedHead ? { expectedHead: expectedHead === 'none' ? null : expectedHead } : {}),
194
+ ...(expectedRevision ? { expectedPolicyRevision: Number(expectedRevision) } : {}) });
195
+ print({ status: result.status, proposal: result.proposal, published: result.compacted,
196
+ estimated_prompt_tokens: result.messages.length ? Math.ceil(Buffer.byteLength(JSON.stringify(result.messages)) / 3) : 0 });
197
+ return;
198
+ }
199
+ finally {
200
+ store.close();
201
+ }
202
+ }
203
+ if (command === 'jobs' && subcommand === 'definitions') {
204
+ const action = id;
205
+ const registry = pluginJobHandlers(plugins, validationContext(loaded.config, dir, env), BUILTIN_JOB_VALIDATION_REGISTRY);
206
+ const source = action === 'validate' ? value(parsed.options, 'source') : join(dir, 'jobs', 'active.json');
207
+ if (!source || (source !== '/dev/stdin' && !isAbsolute(source)))
208
+ throw new Error('Use jobs definitions validate --source <absolute file or /dev/stdin>');
209
+ if (source !== '/dev/stdin') {
210
+ const stat = await lstat(source);
211
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 512 * 1024)
212
+ throw new Error('Job definition source must be a bounded regular file');
213
+ }
214
+ let contents;
215
+ if (source === '/dev/stdin') {
216
+ // Opening /dev/stdin as a path fails with ENXIO for a piped Node child
217
+ // on some hosts. Consume the inherited pipe directly and keep its bound.
218
+ let input = '';
219
+ for await (const chunk of stdin) {
220
+ input += String(chunk);
221
+ if (Buffer.byteLength(input) > 512 * 1024)
222
+ throw new Error('Job definition source exceeds 512 KiB');
223
+ }
224
+ contents = input;
225
+ }
226
+ else
227
+ contents = await readFile(source, 'utf8');
228
+ if (Buffer.byteLength(contents) > 512 * 1024)
229
+ throw new Error('Job definition source exceeds 512 KiB');
230
+ const validated = validateJobDefinitions(JSON.parse(contents), registry, loaded.config.profile);
231
+ const sha256 = createHash('sha256').update(contents).digest('hex');
232
+ if (action === 'validate') {
233
+ print({ profile, revision: validated.definitions.revision, sha256, job_count: validated.job_count });
234
+ return;
235
+ }
236
+ if (action === 'status') {
237
+ let activeJobs = 0;
238
+ let unknownJobs = 0;
239
+ const dbPath = join(dir, 'state', 'ardent.sqlite');
240
+ const dbStat = await lstat(dbPath);
241
+ if (!dbStat.isFile() || dbStat.isSymbolicLink())
242
+ throw new Error('Profile job database is unavailable');
243
+ const database = new Database(dbPath, { readonly: true, fileMustExist: true });
244
+ try {
245
+ const table = database.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='job_windows'").get();
246
+ if (table) {
247
+ const count = z.object({ n: z.number().int().nonnegative() });
248
+ activeJobs = count.parse(database.prepare("SELECT count(*) AS n FROM job_windows WHERE state IN ('queued','running','retry')").get()).n;
249
+ unknownJobs = count.parse(database.prepare("SELECT count(*) AS n FROM job_windows WHERE state='unknown'").get()).n;
250
+ }
251
+ }
252
+ finally {
253
+ database.close();
254
+ }
255
+ print({ profile, revision: validated.definitions.revision, sha256, job_count: validated.job_count,
256
+ active_jobs: activeJobs, unknown_jobs: unknownJobs, last_validation_error: null });
257
+ return;
258
+ }
259
+ throw new Error('Use jobs definitions validate or jobs definitions status');
260
+ }
261
+ if (command === 'admission') {
262
+ const store = new TaskStore(join(dir, 'state', 'ardent.sqlite'));
263
+ try {
264
+ if (subcommand === 'status') {
265
+ print({ profile, fence: store.admissionFence() });
266
+ return;
267
+ }
268
+ if (subcommand === 'close') {
269
+ const releaseId = value(parsed.options, 'release-id');
270
+ if (!releaseId)
271
+ throw new Error('Use admission close --release-id <UUID>');
272
+ print({ profile, fence: store.closeAdmission(releaseId) });
273
+ return;
274
+ }
275
+ throw new Error('Use admission status or admission close --release-id <UUID>');
276
+ }
277
+ finally {
278
+ store.close();
279
+ }
280
+ }
281
+ if (command === 'profile' && subcommand === 'show') {
282
+ print({ profile: loaded.config.profile, path: dir, name: loaded.config.name });
283
+ return;
284
+ }
285
+ if (command === 'config' && subcommand === 'validate') {
286
+ print({ valid: true, revision: loaded.revision, runtime_problems: runtimeProblems(loaded.config, env) });
287
+ return;
288
+ }
289
+ if (command === 'config' && subcommand === 'show') {
290
+ print({ revision: loaded.revision, config: redactedConfig(loaded.config) });
291
+ return;
292
+ }
293
+ if (command === 'config' && subcommand === 'schema') {
294
+ print(toJsonSchema(ConfigSchema));
295
+ return;
296
+ }
297
+ if (command === 'doctor') {
298
+ print({ profile: loaded.config.profile, config_valid: true, runtime_problems: runtimeProblems(loaded.config, env), checked_at: new Date().toISOString() });
299
+ return;
300
+ }
301
+ if (command === 'a2a' && subcommand === 'team-card') {
302
+ if (!loaded.config.a2a.hermes_compat)
303
+ throw new Error('Hermes team card requires a2a.hermes_compat: true');
304
+ const runtime = new ArdentRuntime(dir, loaded.config, env);
305
+ try {
306
+ await runtime.usePlugins(plugins, env);
307
+ print(await hermesTeamCard(runtime, loaded.config));
308
+ }
309
+ finally {
310
+ runtime.close();
311
+ }
312
+ return;
313
+ }
314
+ if (command === 'providers' && subcommand === 'list') {
315
+ print([{ name: 'llamacpp', selected: loaded.config.execution.backend === 'llamacpp' }, { name: 'codex', selected: loaded.config.execution.backend === 'codex' }]);
316
+ return;
317
+ }
318
+ if (command === 'skills') {
319
+ if (subcommand === 'list') {
320
+ print(await listSkills(dir, loaded.config));
321
+ return;
322
+ }
323
+ if (!id)
324
+ throw new Error('Skill name required');
325
+ if (subcommand === 'show') {
326
+ stdout.write(await readSkill(dir, loaded.config, id));
327
+ return;
328
+ }
329
+ if (subcommand === 'enable' || subcommand === 'disable') {
330
+ print({ revision: await setSkillEnabled(dir, id, subcommand === 'enable', loaded.revision) });
331
+ return;
332
+ }
333
+ throw new Error('Unknown skills command');
334
+ }
335
+ if (command === 'memory' && subcommand === 'search') {
336
+ const query = parsed.words.slice(2).join(' ');
337
+ if (!query)
338
+ throw new Error('Memory search query required');
339
+ if (loaded.config.memory.provider !== 'hivemem')
340
+ throw new Error('Memory search requires memory.provider: hivemem');
341
+ print(await new HiveMemClient(loaded.config.memory, env).search(query, 'private'));
342
+ return;
343
+ }
344
+ if (command === 'openapi' && subcommand === 'export') {
345
+ print(ardentOpenApi());
346
+ return;
347
+ }
348
+ if (command === 'auth' && subcommand === 'codex') {
349
+ const action = parsed.words[2];
350
+ await assertCodexProtocol(loaded.config.providers.codex.command, env);
351
+ const server = new CodexAppServer(loaded.config.providers.codex.command, dir, env);
352
+ try {
353
+ await server.initialize();
354
+ if (action === 'status')
355
+ print({ provider: 'codex', auth_mode: await server.accountStatus() });
356
+ else if (action === 'login') {
357
+ const authMode = loaded.config.providers.codex.auth_mode;
358
+ if (await server.accountStatus() === authMode) {
359
+ print({ provider: 'codex', auth_mode: authMode, already_authenticated: true });
360
+ return;
361
+ }
362
+ if (authMode !== 'chatgpt')
363
+ throw new Error('Device login is for auth_mode chatgpt; sign Codex in with an API key using the Codex CLI');
364
+ const flow = await server.startDeviceLogin();
365
+ print({ provider: 'codex', device_login: flow, status: 'pending' });
366
+ let interrupted = false;
367
+ const interrupt = () => { interrupted = true; };
368
+ process.once('SIGINT', interrupt);
369
+ process.once('SIGTERM', interrupt);
370
+ try {
371
+ const deadline = Date.now() + 15 * 60_000;
372
+ while (Date.now() < deadline) {
373
+ await new Promise(resolve => setTimeout(resolve, 2000));
374
+ if (interrupted) {
375
+ await server.cancelLogin(flow.loginId).catch(() => { });
376
+ print({ provider: 'codex', login_id: flow.loginId, status: 'cancelled' });
377
+ return;
378
+ }
379
+ if (await server.accountStatus() === 'chatgpt') {
380
+ print({ provider: 'codex', login_id: flow.loginId, status: 'completed', auth_mode: 'chatgpt' });
381
+ return;
382
+ }
383
+ }
384
+ await server.cancelLogin(flow.loginId).catch(() => { });
385
+ throw new Error('Codex device login expired after 15 minutes');
386
+ }
387
+ finally {
388
+ process.off('SIGINT', interrupt);
389
+ process.off('SIGTERM', interrupt);
390
+ }
391
+ }
392
+ else if (action === 'cancel') {
393
+ const loginId = value(parsed.options, 'login-id');
394
+ if (!loginId)
395
+ throw new Error('Use --login-id to cancel a Codex device login');
396
+ await server.cancelLogin(loginId);
397
+ print({ provider: 'codex', login_id: loginId, cancelled: true });
398
+ }
399
+ else
400
+ throw new Error('Use auth codex status, login, or cancel --login-id <id>');
401
+ }
402
+ finally {
403
+ server.close();
404
+ }
405
+ return;
406
+ }
407
+ if (command === 'a2a') {
408
+ const peer = id;
409
+ if (!peer)
410
+ throw new Error('A2A peer ID required');
411
+ const client = new ArdentA2AClient(dir, loaded.config, env);
412
+ if (subcommand === 'discover') {
413
+ const { card } = await client.open(peer);
414
+ print(card);
415
+ return;
416
+ }
417
+ if (subcommand === 'call') {
418
+ const prompt = value(parsed.options, 'prompt') ?? parsed.words.slice(3).join(' ');
419
+ if (!prompt)
420
+ throw new Error('Use --prompt for an A2A call');
421
+ print(await client.send(peer, prompt));
422
+ return;
423
+ }
424
+ throw new Error('Use a2a discover <peer> or a2a call <peer> --prompt <text>');
425
+ }
426
+ if (command === 'providers' && subcommand === 'test') {
427
+ if (loaded.config.execution.backend === 'codex') {
428
+ await assertCodexProtocol(loaded.config.providers.codex.command, env);
429
+ const server = new CodexAppServer(loaded.config.providers.codex.command, dir, env);
430
+ try {
431
+ await server.initialize();
432
+ print({ provider: 'codex', auth_mode: await server.accountStatus(), protocol: 'backward-compatibility-assumed' });
433
+ }
434
+ finally {
435
+ server.close();
436
+ }
437
+ }
438
+ else {
439
+ const result = await new LlamaCppBackend(loaded.config.providers.llamacpp, env).complete([{ role: 'user', content: 'Reply with READY.' }], [], AbortSignal.timeout(20000), false);
440
+ print({ provider: 'llamacpp', model: resolveReference(loaded.config.providers.llamacpp.model, env), answer: result.content, usage: result.usage ?? null, checked_at: new Date().toISOString() });
441
+ }
442
+ return;
443
+ }
444
+ if (!['run', 'report', 'chat', 'tasks', 'sessions', 'gateway'].includes(command ?? ''))
445
+ throw new Error(`Unknown command.\n${USAGE}`);
446
+ if (command === 'gateway' && subcommand === 'run') {
447
+ if (!['127.0.0.1', '::1', 'localhost'].includes(loaded.config.api.host))
448
+ throw new Error('Non-local API listener requires verified TLS proxy configuration');
449
+ {
450
+ const gate = new TaskStore(join(dir, 'state', 'ardent.sqlite'));
451
+ try {
452
+ if (gate.admissionFence())
453
+ throw new Error('Profile admission is fenced; reconcile the exact rollback release before starting consumers');
454
+ }
455
+ finally {
456
+ gate.close();
457
+ }
458
+ }
459
+ const lockPath = join(dir, 'state', 'gateway.lock');
460
+ const lock = await acquireGatewayLock(lockPath);
461
+ let runtime;
462
+ let shutdownUnsettled = false;
463
+ try {
464
+ runtime = new ArdentRuntime(dir, loaded.config, env);
465
+ await runtime.usePlugins(plugins, env);
466
+ // Legacy scheduled tasks are not replayed by a new gateway generation.
467
+ // The job engine owns its own window recovery and never replays uncertain effects.
468
+ for (const task of runtime.recovered.queued)
469
+ if (task.caller === 'management')
470
+ void runtime.run(task.id, env).catch(() => { });
471
+ const jobPath = join(dir, 'jobs', 'active.json');
472
+ const hasJobs = await lstat(jobPath).then(stat => {
473
+ if (!stat.isFile() || stat.isSymbolicLink())
474
+ throw new Error('Active job definitions are not a regular file');
475
+ return true;
476
+ }).catch((error) => {
477
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT')
478
+ return false;
479
+ throw error;
480
+ });
481
+ const ctx = pluginContext(runtime, env);
482
+ const jobRegistry = pluginJobHandlers(plugins, ctx, builtinJobHandlers(runtime, env));
483
+ const ownership = composeOwnershipChecks(plugins.flatMap((plugin) => plugin.jobOwnership ? [plugin.jobOwnership(ctx)] : []));
484
+ const jobEngine = hasJobs ? new JobEngine(runtime.store.db, dir, loaded.config.profile, jobRegistry, ownership) : undefined;
485
+ if (jobEngine)
486
+ await jobEngine.load();
487
+ if (jobEngine)
488
+ await jobEngine.recoverAfterRestart();
489
+ const api = createArdentApi(runtime, loaded, env, jobEngine);
490
+ const server = serve({ fetch: api.fetch, hostname: loaded.config.api.host, port: loaded.config.api.port });
491
+ await new Promise((resolve, reject) => { server.once('listening', resolve); server.once('error', reject); });
492
+ let a2aHttp;
493
+ let a2aGrpc;
494
+ let a2aSurface;
495
+ // Shutdown first stops intake (polling, listening, new job windows and
496
+ // outbox claims), then drains, and only then aborts in-flight delivery.
497
+ const channelAbort = new AbortController();
498
+ const deliveryAbort = new AbortController();
499
+ let telegramRun;
500
+ let mattermostRun;
501
+ let jobTimer;
502
+ const noticeRuns = new Set();
503
+ try {
504
+ if (jobEngine) {
505
+ const tickJobs = () => {
506
+ const operation = jobEngine.tick(new Date(), deliveryAbort.signal, channelAbort.signal)
507
+ .catch(error => runtime?.store.setCursor('jobs:last_error', error instanceof Error ? error.message.slice(0, 500) : 'unknown'));
508
+ noticeRuns.add(operation);
509
+ void operation.finally(() => noticeRuns.delete(operation));
510
+ };
511
+ jobTimer = setInterval(tickJobs, 60_000);
512
+ tickJobs();
513
+ }
514
+ if (loaded.config.a2a.enabled) {
515
+ const surface = await createA2ASurface(runtime, loaded.config, env);
516
+ a2aSurface = surface;
517
+ const publicAddress = resolveReference(loaded.config.a2a.public_url, env);
518
+ if (!publicAddress)
519
+ throw new Error('A2A public URL is unconfigured');
520
+ const a2aPort = loaded.config.a2a.listen_port ?? Number(new URL(publicAddress).port);
521
+ if (!a2aPort || a2aPort === loaded.config.api.port)
522
+ throw new Error('A2A public URL must have a port distinct from the management API');
523
+ a2aHttp = await new Promise((resolve, reject) => {
524
+ const started = surface.app.listen(a2aPort, loaded.config.a2a.listen_host);
525
+ started.once('listening', () => resolve(started));
526
+ started.once('error', reject);
527
+ });
528
+ if (loaded.config.a2a.bindings.includes('grpc')) {
529
+ await startA2AGrpc(surface, loaded.config.a2a.listen_host);
530
+ a2aGrpc = surface.grpcServer;
531
+ }
532
+ }
533
+ if (loaded.config.channels.telegram.enabled) {
534
+ const fixtureBase = env.ARDENT_TELEGRAM_API_FIXTURE_URL;
535
+ if (fixtureBase && (env.NODE_ENV !== 'test' || !/^http:\/\/(?:127\.0\.0\.1|localhost):\d+$/.test(fixtureBase)))
536
+ throw new Error('Telegram API fixture URL is permitted only for loopback tests');
537
+ const telegram = new TelegramAdapter(runtime, loaded.config.channels.telegram, env, fixtureBase ?? 'https://api.telegram.org', jobEngine);
538
+ telegramRun = telegram.run(channelAbort.signal, deliveryAbort.signal);
539
+ }
540
+ if (loaded.config.channels.mattermost.enabled) {
541
+ const mattermost = new MattermostAdapter(runtime, loaded.config.channels.mattermost, env, jobEngine);
542
+ mattermostRun = mattermost.run(channelAbort.signal);
543
+ }
544
+ print({ profile, host: loaded.config.api.host, port: loaded.config.api.port, a2a: a2aHttp ? 'listening' : 'disabled', status: 'listening' });
545
+ await new Promise((resolve, reject) => {
546
+ const stop = () => resolve();
547
+ server.once('error', reject);
548
+ a2aHttp?.once('error', reject);
549
+ void telegramRun?.catch(reject);
550
+ void mattermostRun?.catch(reject);
551
+ process.once('SIGINT', stop);
552
+ process.once('SIGTERM', stop);
553
+ });
554
+ }
555
+ finally {
556
+ if (jobTimer)
557
+ clearInterval(jobTimer);
558
+ channelAbort.abort();
559
+ shutdownUnsettled = !(await runtime.drain(30_000));
560
+ // Replies and notices admitted before shutdown may still be sending.
561
+ // Give them a bounded window; an aborted send stays unknown, never retried.
562
+ let deliveryTimer;
563
+ await Promise.race([
564
+ Promise.allSettled([...noticeRuns, ...(telegramRun ? [telegramRun] : []), ...(mattermostRun ? [mattermostRun] : [])]),
565
+ new Promise(resolve => { deliveryTimer = setTimeout(resolve, 20_000); })
566
+ ]);
567
+ if (deliveryTimer)
568
+ clearTimeout(deliveryTimer);
569
+ deliveryAbort.abort();
570
+ await Promise.allSettled([...noticeRuns]);
571
+ await telegramRun?.catch(() => { });
572
+ await mattermostRun?.catch(() => { });
573
+ await new Promise(resolve => server.close(() => resolve()));
574
+ const httpServer = a2aHttp;
575
+ const grpcServer = a2aGrpc;
576
+ if (httpServer)
577
+ await new Promise(resolve => httpServer.close(() => resolve()));
578
+ if (grpcServer)
579
+ await new Promise(resolve => grpcServer.tryShutdown(() => resolve()));
580
+ if (a2aSurface) {
581
+ try {
582
+ await a2aSurface.pushSender.drain();
583
+ }
584
+ catch {
585
+ shutdownUnsettled = true;
586
+ }
587
+ }
588
+ }
589
+ }
590
+ finally {
591
+ if (runtime) {
592
+ const settled = await runtime.drain(shutdownUnsettled ? 0 : 30_000);
593
+ shutdownUnsettled ||= !settled;
594
+ if (settled)
595
+ runtime.close();
596
+ }
597
+ await lock.release();
598
+ }
599
+ if (shutdownUnsettled) {
600
+ stderr.write('Gateway shutdown left unresolved work; interrupted tasks will be marked unknown on restart.\n');
601
+ process.exit(1);
602
+ }
603
+ return;
604
+ }
605
+ const ownerLockPath = join(dir, 'state', 'gateway.lock');
606
+ const ownerLock = await createLockFile(ownerLockPath).catch(async (error) => {
607
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'EEXIST') {
608
+ await assertNoGateway(dir);
609
+ throw new Error('Profile is already owned by another Ardent process');
610
+ }
611
+ throw error;
612
+ });
613
+ let runtime;
614
+ try {
615
+ runtime = new ArdentRuntime(dir, loaded.config, env);
616
+ await runtime.usePlugins(plugins, env);
617
+ }
618
+ catch (error) {
619
+ await ownerLock.release();
620
+ throw error;
621
+ }
622
+ try {
623
+ if (command === 'tasks') {
624
+ if (subcommand === 'list') {
625
+ print(runtime.store.list());
626
+ return;
627
+ }
628
+ if (!id)
629
+ throw new Error('Task ID required');
630
+ if (subcommand === 'show') {
631
+ print({ task: runtime.store.get(id), events: runtime.store.events(id) });
632
+ return;
633
+ }
634
+ if (subcommand === 'cancel') {
635
+ print(runtime.cancel(id));
636
+ return;
637
+ }
638
+ if (subcommand === 'resume') {
639
+ const task = runtime.store.get(id);
640
+ if (!task)
641
+ throw new Error('Task not found');
642
+ if (task.state !== 'queued')
643
+ throw new Error(`Task is ${task.state}; only unstarted queued work can be resumed without replaying an uncertain effect`);
644
+ print(await runtime.run(task.id, env));
645
+ return;
646
+ }
647
+ throw new Error('Unknown tasks command');
648
+ }
649
+ if (command === 'sessions') {
650
+ if (subcommand === 'list') {
651
+ print(runtime.store.listSessions('local', 'direct'));
652
+ return;
653
+ }
654
+ if (subcommand === 'new' || subcommand === 'reset') {
655
+ print(runtime.store.resetSession('local', 'direct', id ?? 'cli'));
656
+ return;
657
+ }
658
+ if (subcommand === 'show' && id) {
659
+ print({ messages: runtime.store.sessionMessages(id, 'local') });
660
+ return;
661
+ }
662
+ throw new Error('Use sessions list, new [key], reset [key], or show <id>');
663
+ }
664
+ if (command === 'run' || command === 'report') {
665
+ let prompt = value(parsed.options, 'prompt');
666
+ const promptFile = value(parsed.options, 'prompt-file');
667
+ if (promptFile)
668
+ prompt = await readFile(promptFile, 'utf8');
669
+ if (parsed.options.has('stdin'))
670
+ prompt = await new Promise((resolve, reject) => {
671
+ let input = '';
672
+ stdin.setEncoding('utf8');
673
+ stdin.on('data', (chunk) => { input += chunk; if (input.length > 16000)
674
+ reject(new Error('Prompt exceeds 16000 characters')); });
675
+ stdin.on('end', () => resolve(input));
676
+ stdin.on('error', reject);
677
+ });
678
+ if (!prompt?.trim())
679
+ throw new Error('Use --prompt, --prompt-file, or --stdin');
680
+ let caller = 'local';
681
+ let context = 'direct';
682
+ let idempotencyKey = value(parsed.options, 'idempotency-key');
683
+ if (command === 'report') {
684
+ if (!parsed.options.has('non-interactive'))
685
+ throw new Error('Scheduled report requires --non-interactive');
686
+ const scheduleId = value(parsed.options, 'schedule-id');
687
+ const windowKey = value(parsed.options, 'window-key');
688
+ if (!scheduleId || !/^[A-Za-z0-9._-]{1,100}$/.test(scheduleId) || !windowKey || !/^[A-Za-z0-9._:-]{1,100}$/.test(windowKey))
689
+ throw new Error('Scheduled report requires a safe --schedule-id and --window-key');
690
+ caller = `schedule:${scheduleId}`;
691
+ context = 'scheduled';
692
+ idempotencyKey = `report:${windowKey}`;
693
+ }
694
+ const task = runtime.accept(prompt, caller, context, idempotencyKey, command === 'run' ? value(parsed.options, 'session-id') : undefined);
695
+ const result = await runtime.run(task.id, env);
696
+ if (parsed.options.has('json'))
697
+ print(result);
698
+ else
699
+ stdout.write(`${result.answer}\n`);
700
+ return;
701
+ }
702
+ if (command === 'chat') {
703
+ const rl = createInterface({ input: stdin, output: stdout });
704
+ let session = runtime.store.session('local', 'direct', 'cli');
705
+ let activeTask;
706
+ let stopping = false;
707
+ let lastInterrupt = 0;
708
+ const interrupt = () => {
709
+ if (Date.now() - lastInterrupt < 100)
710
+ return;
711
+ lastInterrupt = Date.now();
712
+ if (activeTask) {
713
+ stdout.write(`\nCancelling task ${activeTask}...\n`);
714
+ try {
715
+ runtime.cancel(activeTask);
716
+ }
717
+ catch { /* Terminal task already settled. */ }
718
+ }
719
+ else {
720
+ stopping = true;
721
+ rl.close();
722
+ }
723
+ };
724
+ process.on('SIGINT', interrupt);
725
+ rl.on('SIGINT', interrupt);
726
+ try {
727
+ while (!stopping) {
728
+ let prompt;
729
+ try {
730
+ prompt = await rl.question(`${loaded.config.profile}> `);
731
+ }
732
+ catch (error) {
733
+ if (stopping)
734
+ break;
735
+ throw error;
736
+ }
737
+ if (['/exit', '/quit'].includes(prompt.trim()))
738
+ break;
739
+ if (!prompt.trim())
740
+ continue;
741
+ if (['/new', '/reset'].includes(prompt.trim())) {
742
+ session = runtime.store.resetSession('local', 'direct', 'cli');
743
+ stdout.write(`New session ${session.id}\n`);
744
+ continue;
745
+ }
746
+ if (prompt.trim() === '/status') {
747
+ print({ session_id: session.id, profile, backend: loaded.config.execution.backend });
748
+ continue;
749
+ }
750
+ if (prompt.trim() === '/tasks') {
751
+ print(runtime.store.list());
752
+ continue;
753
+ }
754
+ const task = runtime.accept(prompt, 'local', 'direct', undefined, session.id);
755
+ activeTask = task.id;
756
+ let cursor = 0;
757
+ let answerStarted = false;
758
+ const showProgress = () => {
759
+ if (answerStarted)
760
+ return;
761
+ for (const event of runtime.store.events(task.id, cursor, 100)) {
762
+ cursor = event.sequence;
763
+ if (event.kind === 'model_started')
764
+ stdout.write(' Thinking...\n');
765
+ else if (event.kind === 'tool_attempt') {
766
+ const name = z.object({ name: z.string() }).safeParse(event.detail);
767
+ if (name.success)
768
+ stdout.write(` Calling ${name.data.name}...\n`);
769
+ }
770
+ else if (event.kind === 'tool_result') {
771
+ const value = z.object({ name: z.string(), result: z.object({ ok: z.boolean() }).passthrough() }).safeParse(event.detail);
772
+ if (value.success)
773
+ stdout.write(` ${value.data.name}: ${value.data.result.ok ? 'done' : 'unavailable'}\n`);
774
+ }
775
+ else if (event.kind === 'research_artifacts')
776
+ stdout.write(' Research artifacts saved.\n');
777
+ }
778
+ };
779
+ const progress = setInterval(showProgress, 150);
780
+ const streamState = { text: '', step: -1, mode: 'pending' };
781
+ const writeModelText = (chunk, step) => {
782
+ if (step !== streamState.step) {
783
+ streamState.step = step;
784
+ streamState.text = '';
785
+ streamState.mode = 'pending';
786
+ }
787
+ if (streamState.mode === 'structured')
788
+ return;
789
+ if (streamState.mode === 'pending') {
790
+ const first = chunk.trimStart()[0];
791
+ if (!first) {
792
+ streamState.text += chunk;
793
+ return;
794
+ }
795
+ streamState.mode = first === '{' || first === '[' ? 'structured' : 'plain';
796
+ if (streamState.mode === 'structured') {
797
+ streamState.text = '';
798
+ return;
799
+ }
800
+ showProgress();
801
+ answerStarted = true;
802
+ if (streamState.text)
803
+ stdout.write(streamState.text);
804
+ }
805
+ streamState.text += chunk;
806
+ stdout.write(chunk);
807
+ };
808
+ try {
809
+ const result = await runtime.run(task.id, env, writeModelText);
810
+ showProgress();
811
+ if (streamState.mode !== 'plain' || streamState.text.trim() !== result.answer.trim())
812
+ stdout.write(`${result.answer}\n`);
813
+ else if (!streamState.text.endsWith('\n'))
814
+ stdout.write('\n');
815
+ }
816
+ catch (error) {
817
+ showProgress();
818
+ stderr.write(`${error instanceof Error ? error.message : 'Task failed'}\n`);
819
+ }
820
+ finally {
821
+ clearInterval(progress);
822
+ activeTask = undefined;
823
+ }
824
+ }
825
+ }
826
+ finally {
827
+ process.off('SIGINT', interrupt);
828
+ rl.off('SIGINT', interrupt);
829
+ rl.close();
830
+ }
831
+ }
832
+ }
833
+ finally {
834
+ try {
835
+ runtime.close();
836
+ }
837
+ finally {
838
+ await ownerLock.release();
839
+ }
840
+ }
841
+ }
842
+ main().catch((error) => {
843
+ stderr.write(`ardent: ${error instanceof Error ? error.message : 'Unknown error'}\n`);
844
+ process.exitCode = 1;
845
+ });