@monster-orange/den 0.4.11 → 0.4.13
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/CHANGELOG.md +169 -0
- package/README.md +7 -7
- package/docs/distributions.md +212 -12
- package/package.json +2 -1
- package/src/shared/agents/catalog.ts +77 -0
- package/src/shared/agents/default-pack/be-dev.md +1 -0
- package/src/shared/agents/default-pack/devops.md +1 -0
- package/src/shared/agents/default-pack/fe-dev.md +1 -0
- package/src/shared/agents/default-pack/orchestrator.md +3 -1
- package/src/shared/agents/default-pack/planner.md +1 -0
- package/src/shared/agents/default-pack/reviewer.md +1 -0
- package/src/shared/agents/default-pack/tester.md +16 -0
- package/src/shared/agents/default-pack/ux-designer.md +1 -0
- package/src/shared/agents/den-claude.ts +151 -17
- package/src/shared/agents/permissions.ts +108 -0
- package/src/shared/agents/persona-store.ts +286 -0
- package/src/shared/agents/personas.ts +116 -12
- package/src/shared/agents/records.ts +57 -0
- package/src/shared/agents/roles.ts +288 -0
- package/src/shared/agents/runtime.ts +49 -0
- package/src/shared/agents/session-name.ts +110 -0
- package/src/shared/agents/transcript-follow.ts +180 -0
- package/src/shared/den-config.ts +27 -1
- package/src/ui/cli/agent-ops.ts +578 -11
- package/src/ui/cli/agents-command.ts +12 -2
- package/src/ui/cli/env-probe.ts +4 -1
- package/src/ui/mcp/tools.ts +1 -1
- package/src/ui/server/agent-paths.ts +52 -0
- package/src/ui/server/agent-routes.ts +329 -19
- package/src/ui/server/agents-layout-pref.ts +43 -0
- package/src/ui/server/editor-open-pref.ts +41 -0
- package/src/ui/server/index.ts +22 -0
- package/src/ui/server/public/agent-start.js +3 -2
- package/src/ui/server/public/agents-page.js +1037 -67
- package/src/ui/server/public/agents-section.js +1 -1
- package/src/ui/server/public/agents.html +236 -20
- package/src/ui/server/public/header.js +2 -0
- package/src/ui/server/public/i18n/es.js +139 -0
- package/src/ui/server/public/i18n/ja.js +139 -0
- package/src/ui/server/public/persona-ui.css +539 -0
- package/src/ui/server/public/persona-ui.js +796 -0
- package/src/ui/server/public/settings-page.js +4 -9
- package/src/ui/server/public/settings-registry.js +66 -0
- package/src/ui/server/public/settings.html +1 -0
- package/src/ui/server/public/shell.js +137 -0
- package/src/ui/server/public/term-input.js +129 -0
- package/src/ui/server/public/topbar.js +22 -4
- package/src/ui/server/route-classes.ts +29 -0
- package/src/ui/server/session-routes.ts +34 -1
- package/src/ui/server/ui-assets.ts +8 -0
package/src/ui/cli/agent-ops.ts
CHANGED
|
@@ -71,6 +71,7 @@ import {
|
|
|
71
71
|
type CatalogEntry,
|
|
72
72
|
} from "../../shared/agents/catalog.js";
|
|
73
73
|
import { checkRuntimeBin } from "../../shared/agents/catalog-runtime.js";
|
|
74
|
+
import { agentSessionName } from "../../shared/agents/session-name.js";
|
|
74
75
|
import { cliEntrypoint } from "../../shared/engine.js";
|
|
75
76
|
import { getDistRoot } from "../../shared/paths.js";
|
|
76
77
|
import { envKey } from "../../shared/product.js";
|
|
@@ -80,6 +81,31 @@ import {
|
|
|
80
81
|
resolvePackDirs,
|
|
81
82
|
type Persona,
|
|
82
83
|
} from "../../shared/agents/personas.js";
|
|
84
|
+
import {
|
|
85
|
+
checkModelChoice,
|
|
86
|
+
checkPersonaInput,
|
|
87
|
+
deleteLocalPersona,
|
|
88
|
+
localPersonaExists,
|
|
89
|
+
localPersonasDir,
|
|
90
|
+
newPersonaId,
|
|
91
|
+
PersonaInputError,
|
|
92
|
+
writeLocalPersona,
|
|
93
|
+
type PersonaInput,
|
|
94
|
+
} from "../../shared/agents/persona-store.js";
|
|
95
|
+
import {
|
|
96
|
+
categoryRegistry,
|
|
97
|
+
roleRegistry,
|
|
98
|
+
rolePreset,
|
|
99
|
+
type Category,
|
|
100
|
+
type RoleTemplate,
|
|
101
|
+
} from "../../shared/agents/roles.js";
|
|
102
|
+
import {
|
|
103
|
+
checkCommands,
|
|
104
|
+
checkIntent,
|
|
105
|
+
intentOf,
|
|
106
|
+
type PermissionIntent,
|
|
107
|
+
} from "../../shared/agents/permissions.js";
|
|
108
|
+
import { softNewlineSeq } from "../../shared/agents/catalog.js";
|
|
83
109
|
import {
|
|
84
110
|
AGENT_ID_RE,
|
|
85
111
|
agentFilesDir,
|
|
@@ -89,8 +115,10 @@ import {
|
|
|
89
115
|
removeRecord,
|
|
90
116
|
restoreRecord,
|
|
91
117
|
writeRecord,
|
|
118
|
+
checkDisplayName,
|
|
92
119
|
writeRuntimeFiles,
|
|
93
120
|
type AgentRecord,
|
|
121
|
+
type Override,
|
|
94
122
|
} from "../../shared/agents/records.js";
|
|
95
123
|
import {
|
|
96
124
|
agentSessionEnv,
|
|
@@ -103,7 +131,11 @@ import {
|
|
|
103
131
|
type LaunchInput,
|
|
104
132
|
type Runtime,
|
|
105
133
|
} from "../../shared/agents/runtimes.js";
|
|
106
|
-
import type {
|
|
134
|
+
import type {
|
|
135
|
+
RuntimeDetect,
|
|
136
|
+
RuntimeModel,
|
|
137
|
+
RuntimePermissions,
|
|
138
|
+
} from "../../shared/agents/runtime.js";
|
|
107
139
|
import {
|
|
108
140
|
archiveHandoff,
|
|
109
141
|
DEFAULT_ROTATE,
|
|
@@ -355,9 +387,28 @@ export function agentMcp(
|
|
|
355
387
|
*/
|
|
356
388
|
export interface AgentRow {
|
|
357
389
|
agent: string;
|
|
390
|
+
/** The name a person gave it (AG-4b), when one did. */
|
|
391
|
+
displayName?: string;
|
|
392
|
+
/**
|
|
393
|
+
* The name its session shows (AG-4b): the latest of Den's rename and the
|
|
394
|
+
* runtime's own (Claude Code's /rename), when there is one. 0.4.12 called
|
|
395
|
+
* this sessionName; sessionName is the record's session handle
|
|
396
|
+
* (`<dist>-<ws>:<id>`), as it has been since 0.4.0.
|
|
397
|
+
*/
|
|
398
|
+
sessionTitle?: string;
|
|
358
399
|
persona?: string;
|
|
359
400
|
runtime?: string;
|
|
360
401
|
model?: string;
|
|
402
|
+
/** Its model version: "latest" or pinned (AG-12.3). */
|
|
403
|
+
version?: string;
|
|
404
|
+
/** The permission intent it started with (AG-13). */
|
|
405
|
+
permission?: PermissionIntent;
|
|
406
|
+
/** Its extra allowed commands, when set for it (AG-13.4). */
|
|
407
|
+
allow?: string[];
|
|
408
|
+
/** What it overrides of its persona (AG-10.9). */
|
|
409
|
+
overrides?: Override[];
|
|
410
|
+
/** A newer version of its pinned model (AG-12.4). */
|
|
411
|
+
newer?: string;
|
|
361
412
|
workspace?: string;
|
|
362
413
|
cwd?: string;
|
|
363
414
|
/** running, stopping (stopped, its session not yet gone), exited
|
|
@@ -460,9 +511,11 @@ export function agentRows(
|
|
|
460
511
|
isStoppedState(st) && !resumable
|
|
461
512
|
? (resumeRefusal(rec) ?? (rot?.ready ? rotatedRefusal(rec, rot) : null))
|
|
462
513
|
: null;
|
|
514
|
+
const named = stateDir ? agentSessionName(rec, stateDir) : undefined;
|
|
463
515
|
return {
|
|
464
516
|
agent: rec.id,
|
|
465
517
|
...recordFields(rec),
|
|
518
|
+
...(named ? { sessionTitle: named } : {}),
|
|
466
519
|
state: st,
|
|
467
520
|
startedAt: s?.startedAt ?? rec.startedAt,
|
|
468
521
|
resumable,
|
|
@@ -489,14 +542,36 @@ export function agentRows(
|
|
|
489
542
|
|
|
490
543
|
function recordFields(rec: AgentRecord): Partial<AgentRow> {
|
|
491
544
|
return {
|
|
545
|
+
...(rec.displayName ? { displayName: rec.displayName } : {}),
|
|
492
546
|
persona: rec.persona,
|
|
493
547
|
runtime: rec.runtime,
|
|
494
548
|
...(rec.model ? { model: rec.model } : {}),
|
|
549
|
+
...(rec.version ? { version: rec.version } : {}),
|
|
550
|
+
...(rec.permission ? { permission: rec.permission } : {}),
|
|
551
|
+
...(rec.allow ? { allow: [...rec.allow] } : {}),
|
|
552
|
+
...(rec.overrides?.length ? { overrides: [...rec.overrides] } : {}),
|
|
553
|
+
...newerOf(rec),
|
|
495
554
|
workspace: rec.workspace,
|
|
496
555
|
cwd: rec.cwd,
|
|
497
556
|
};
|
|
498
557
|
}
|
|
499
558
|
|
|
559
|
+
/**
|
|
560
|
+
* AG-12.4: a pinned version older than its model's newest, as
|
|
561
|
+
* `{ newer: <latest> }`; nothing otherwise.
|
|
562
|
+
*/
|
|
563
|
+
export function newerOf(
|
|
564
|
+
rec: Pick<AgentRecord, "runtime" | "model" | "version">,
|
|
565
|
+
): {
|
|
566
|
+
newer?: string;
|
|
567
|
+
} {
|
|
568
|
+
if (!rec.version || rec.version === "latest" || !rec.model) return {};
|
|
569
|
+
const m = getRuntime(rec.runtime)
|
|
570
|
+
?.models?.()
|
|
571
|
+
.find((x) => x.id === rec.model);
|
|
572
|
+
return m && m.latest !== rec.version ? { newer: m.latest } : {};
|
|
573
|
+
}
|
|
574
|
+
|
|
500
575
|
/**
|
|
501
576
|
* Note in each record whose session the host lists as exited that it
|
|
502
577
|
* ended, with its code (AG-14.1: "Exited (code n)"), so it still says so
|
|
@@ -574,6 +649,7 @@ function personas(
|
|
|
574
649
|
resolvePackDirs(cfg.personaPacks, d.distRoot()),
|
|
575
650
|
undefined,
|
|
576
651
|
{ defaultPack: cfg.defaultPackGuardrails, packs: cfg.packGuardrails },
|
|
652
|
+
localPersonasDir(d.stateDir()),
|
|
577
653
|
);
|
|
578
654
|
} catch (e) {
|
|
579
655
|
throw refused((e as Error).message, [], 422);
|
|
@@ -581,13 +657,112 @@ function personas(
|
|
|
581
657
|
}
|
|
582
658
|
|
|
583
659
|
/** A persona as listed: everything but its role prompt. */
|
|
584
|
-
export type ListedPersona = Omit<Persona, "prompt" | "guardrailsSet"
|
|
660
|
+
export type ListedPersona = Omit<Persona, "prompt" | "guardrailsSet"> & {
|
|
661
|
+
/** Its permission mode as Den's intent (AG-13), when it is one. */
|
|
662
|
+
permission?: PermissionIntent;
|
|
663
|
+
};
|
|
585
664
|
|
|
586
|
-
|
|
665
|
+
function listed(p: Persona): ListedPersona {
|
|
666
|
+
const { prompt: _p, guardrailsSet: _g, ...rest } = p;
|
|
667
|
+
const permission = intentOf(p.permissionMode);
|
|
668
|
+
return { ...rest, ...(permission ? { permission } : {}) };
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/** Den's default pack, then agents.persona_packs, then local ones, by id. */
|
|
587
672
|
export function listPersonas(d: AgentsResolved): ListedPersona[] {
|
|
588
673
|
return [...personas(d, agentsConfig(d)).values()]
|
|
589
674
|
.sort((a, b) => a.id.localeCompare(b.id))
|
|
590
|
-
.map(
|
|
675
|
+
.map(listed);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** A persona with its instructions (Details, Edit: AG-10.6). */
|
|
679
|
+
export function personaDetail(
|
|
680
|
+
d: AgentsResolved,
|
|
681
|
+
id: string,
|
|
682
|
+
): ListedPersona & { instructions: string } {
|
|
683
|
+
const p = personas(d, agentsConfig(d)).get(id);
|
|
684
|
+
if (!p) throw refused(`no persona "${id}"`, [], 404);
|
|
685
|
+
return { ...listed(p), instructions: p.prompt };
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** The role and category registries (AG-10.5, AG-11.1). */
|
|
689
|
+
export function registries(d: AgentsResolved): {
|
|
690
|
+
roles: RoleTemplate[];
|
|
691
|
+
categories: Category[];
|
|
692
|
+
} {
|
|
693
|
+
const cfg = agentsConfig(d);
|
|
694
|
+
return {
|
|
695
|
+
roles: roleRegistry(cfg.roles),
|
|
696
|
+
categories: categoryRegistry(cfg.categories),
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/** What a persona's fields may name (persona-store.ts checks them). */
|
|
701
|
+
function personaChoices(d: AgentsResolved) {
|
|
702
|
+
syncCatalog(d);
|
|
703
|
+
const reg = registries(d);
|
|
704
|
+
return {
|
|
705
|
+
roles: reg.roles.map((r) => r.id),
|
|
706
|
+
categories: reg.categories.map((c) => c.id),
|
|
707
|
+
runtimes: runtimeIds()
|
|
708
|
+
.filter((id) => getRuntime(id)?.catalog || id === DEFAULT_RUNTIME_ID)
|
|
709
|
+
.map((id) => ({ id, models: getRuntime(id)?.models?.() ?? [] })),
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function checkedInput(d: AgentsResolved, body: unknown): PersonaInput {
|
|
714
|
+
try {
|
|
715
|
+
return checkPersonaInput(body, personaChoices(d));
|
|
716
|
+
} catch (e) {
|
|
717
|
+
if (e instanceof PersonaInputError) throw usage(e.message);
|
|
718
|
+
throw e;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* New persona (AG-10.7): a local one, its id from its name (or role),
|
|
724
|
+
* never one any persona has. Returns it as listed.
|
|
725
|
+
*/
|
|
726
|
+
export function createPersona(d: AgentsResolved, body: unknown): ListedPersona {
|
|
727
|
+
const input = checkedInput(d, body);
|
|
728
|
+
const all = personas(d, agentsConfig(d));
|
|
729
|
+
const id = newPersonaId(input, new Set(all.keys()));
|
|
730
|
+
writeLocalPersona(d.stateDir(), id, input);
|
|
731
|
+
return personaDetail(d, id);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Edit persona `id` (AG-10.7): only a local one is written. A built-in or
|
|
736
|
+
* a distribution's persona is read-only: 409, and the page duplicates it.
|
|
737
|
+
*/
|
|
738
|
+
export function updatePersona(
|
|
739
|
+
d: AgentsResolved,
|
|
740
|
+
id: string,
|
|
741
|
+
body: unknown,
|
|
742
|
+
): ListedPersona {
|
|
743
|
+
const p = personas(d, agentsConfig(d)).get(id);
|
|
744
|
+
if (!p) throw refused(`no persona "${id}"`, [], 404);
|
|
745
|
+
if (p.origin !== "local" || !localPersonaExists(d.stateDir(), id))
|
|
746
|
+
throw new AgentError(
|
|
747
|
+
`${id} is ${p.origin === "builtin" ? "built in" : `the ${p.origin}'s`}: duplicate it to edit a copy of your own`,
|
|
748
|
+
{ status: 409, code: "read_only" },
|
|
749
|
+
);
|
|
750
|
+
writeLocalPersona(d.stateDir(), id, checkedInput(d, body));
|
|
751
|
+
return personaDetail(d, id);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/** Delete local persona `id` (never a built-in or a distribution's). */
|
|
755
|
+
export function deletePersona(d: AgentsResolved, id: string): void {
|
|
756
|
+
const p = personas(d, agentsConfig(d)).get(id);
|
|
757
|
+
if (!p) throw refused(`no persona "${id}"`, [], 404);
|
|
758
|
+
if (p.origin !== "local" || !deleteLocalPersona(d.stateDir(), id))
|
|
759
|
+
throw new AgentError(
|
|
760
|
+
`${id} isn't yours to delete: only local personas are`,
|
|
761
|
+
{
|
|
762
|
+
status: 409,
|
|
763
|
+
code: "read_only",
|
|
764
|
+
},
|
|
765
|
+
);
|
|
591
766
|
}
|
|
592
767
|
|
|
593
768
|
export interface RuntimeChoice {
|
|
@@ -621,6 +796,12 @@ export interface RuntimeChoice {
|
|
|
621
796
|
/** It declares resume. */
|
|
622
797
|
resume: boolean;
|
|
623
798
|
default: boolean;
|
|
799
|
+
/** Its models (AG-12.3); [] when it takes no model choice from Den. */
|
|
800
|
+
models: RuntimeModel[];
|
|
801
|
+
/** How it expresses permission intents (AG-13.2), when it can. */
|
|
802
|
+
permissions?: RuntimePermissions;
|
|
803
|
+
/** Its Shift+Enter bytes: also how a first instruction's newlines go. */
|
|
804
|
+
softNewline?: string;
|
|
624
805
|
}
|
|
625
806
|
|
|
626
807
|
/**
|
|
@@ -749,6 +930,8 @@ export async function listRuntimes(
|
|
|
749
930
|
: {}),
|
|
750
931
|
resume: Boolean(rt.resume),
|
|
751
932
|
default: id === DEFAULT_RUNTIME_ID,
|
|
933
|
+
models: rt.models?.() ?? [],
|
|
934
|
+
...(rt.permissions ? { permissions: rt.permissions } : {}),
|
|
752
935
|
};
|
|
753
936
|
}),
|
|
754
937
|
);
|
|
@@ -1025,6 +1208,8 @@ function launchInput(
|
|
|
1025
1208
|
bin?: string,
|
|
1026
1209
|
/** The grants to serve (a resume's, narrowed); default the config's. */
|
|
1027
1210
|
only?: readonly string[],
|
|
1211
|
+
/** AG-13: the intent and the allowed commands (preset and extras). */
|
|
1212
|
+
perms: { permission?: PermissionIntent; allow?: string[] } = {},
|
|
1028
1213
|
): LaunchInput {
|
|
1029
1214
|
const grants = only ? [...only] : (cfg.agents[id]?.grants ?? []);
|
|
1030
1215
|
for (const g of grants)
|
|
@@ -1053,9 +1238,32 @@ function launchInput(
|
|
|
1053
1238
|
env: d.env,
|
|
1054
1239
|
...(bin ? { bin } : {}),
|
|
1055
1240
|
rotateAfter: after,
|
|
1241
|
+
...(perms.permission ? { permission: perms.permission } : {}),
|
|
1242
|
+
...(perms.allow?.length ? { allow: [...perms.allow] } : {}),
|
|
1056
1243
|
};
|
|
1057
1244
|
}
|
|
1058
1245
|
|
|
1246
|
+
/** The model a runtime is given: a pinned version, else the model. */
|
|
1247
|
+
export function launchModel(
|
|
1248
|
+
model: string | undefined,
|
|
1249
|
+
version: string | undefined,
|
|
1250
|
+
): string | undefined {
|
|
1251
|
+
return version && version !== "latest" ? version : model;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
/**
|
|
1255
|
+
* The commands an agent may run without asking (AG-13.3, 13.4): its
|
|
1256
|
+
* persona role's preset, then its extra commands.
|
|
1257
|
+
*/
|
|
1258
|
+
function allowedFor(
|
|
1259
|
+
d: AgentsResolved,
|
|
1260
|
+
persona: Persona,
|
|
1261
|
+
extras: readonly string[],
|
|
1262
|
+
): string[] {
|
|
1263
|
+
const preset = rolePreset(registries(d).roles, persona.role);
|
|
1264
|
+
return [...new Set([...preset, ...extras])];
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1059
1267
|
/** The rotation limit a launch counts against: 0 when its runtime can't relaunch. */
|
|
1060
1268
|
function launchAfter(rt: Runtime, input: LaunchInput): number {
|
|
1061
1269
|
return rt.firstPrompt ? (input.rotateAfter ?? 0) : 0;
|
|
@@ -1096,6 +1304,112 @@ export interface StartRequest {
|
|
|
1096
1304
|
runtime?: string;
|
|
1097
1305
|
model?: string;
|
|
1098
1306
|
id?: string;
|
|
1307
|
+
/** "latest" or a pinned version of the model (AG-12.3). */
|
|
1308
|
+
version?: string;
|
|
1309
|
+
/** The permission intent for this launch (AG-13.6). */
|
|
1310
|
+
permission?: string;
|
|
1311
|
+
/** Extra allowed commands for this launch, instead of the persona's. */
|
|
1312
|
+
allow?: unknown;
|
|
1313
|
+
/**
|
|
1314
|
+
* The page's start (AG-10.1): a model must be one the runtime offers,
|
|
1315
|
+
* never a free string (the CLI's --model for generic stays free).
|
|
1316
|
+
*/
|
|
1317
|
+
strict?: boolean;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
/** What a start resolved, checked, before it launches. */
|
|
1321
|
+
interface Choices {
|
|
1322
|
+
model?: string;
|
|
1323
|
+
version: string;
|
|
1324
|
+
permission?: PermissionIntent;
|
|
1325
|
+
extras: string[];
|
|
1326
|
+
overrides: Override[];
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
/**
|
|
1330
|
+
* The model, version, permission and extra commands a start runs with
|
|
1331
|
+
* (AG-10.9, 12.3, 13.6): the request's, else the persona's; each checked
|
|
1332
|
+
* against what the runtime offers. What differs from the persona is an
|
|
1333
|
+
* override, and the list tags it.
|
|
1334
|
+
*/
|
|
1335
|
+
function startChoices(
|
|
1336
|
+
req: StartRequest,
|
|
1337
|
+
persona: Persona,
|
|
1338
|
+
rt: Runtime,
|
|
1339
|
+
): Choices {
|
|
1340
|
+
const where = "agents start";
|
|
1341
|
+
const models = rt.models?.() ?? [];
|
|
1342
|
+
const overrides: Override[] = [];
|
|
1343
|
+
const personaRt = persona.runtime ?? DEFAULT_RUNTIME_ID;
|
|
1344
|
+
if (req.runtime && req.runtime !== personaRt) overrides.push("runtime");
|
|
1345
|
+
// The persona's model is for its own runtime only.
|
|
1346
|
+
const sameRt = rt.id === personaRt;
|
|
1347
|
+
const personaModel = sameRt ? persona.model : undefined;
|
|
1348
|
+
const personaVersion = (sameRt && persona.version) || "latest";
|
|
1349
|
+
let model = req.model ?? personaModel;
|
|
1350
|
+
let version =
|
|
1351
|
+
req.version ??
|
|
1352
|
+
(req.model === undefined || req.model === personaModel
|
|
1353
|
+
? personaVersion
|
|
1354
|
+
: "latest");
|
|
1355
|
+
if (req.model !== undefined && req.strict && !models.length)
|
|
1356
|
+
throw usage(`${where}: ${rt.id} takes no model choice from Den`);
|
|
1357
|
+
if (req.version !== undefined && req.version !== "latest" && !models.length)
|
|
1358
|
+
throw usage(`${where}: ${rt.id} takes no model version from Den`);
|
|
1359
|
+
// The page's choices are checked against the catalog; the CLI's --model
|
|
1360
|
+
// stays as given (a full model name), unless a version comes with it.
|
|
1361
|
+
if (
|
|
1362
|
+
models.length &&
|
|
1363
|
+
(req.strict || req.version !== undefined) &&
|
|
1364
|
+
(req.model !== undefined || req.version !== undefined)
|
|
1365
|
+
)
|
|
1366
|
+
try {
|
|
1367
|
+
const c = checkModelChoice({ id: rt.id, models }, model, version);
|
|
1368
|
+
model = c.model;
|
|
1369
|
+
version = c.version;
|
|
1370
|
+
} catch (e) {
|
|
1371
|
+
throw usage(`${where}: ${(e as Error).message}`);
|
|
1372
|
+
}
|
|
1373
|
+
if (req.model !== undefined && req.model !== personaModel)
|
|
1374
|
+
overrides.push("model");
|
|
1375
|
+
if (version !== personaVersion) overrides.push("version");
|
|
1376
|
+
let permission: PermissionIntent | undefined;
|
|
1377
|
+
if (req.permission !== undefined) {
|
|
1378
|
+
try {
|
|
1379
|
+
permission = checkIntent(req.permission, "permission");
|
|
1380
|
+
} catch (e) {
|
|
1381
|
+
throw usage(`${where}: ${(e as Error).message}`);
|
|
1382
|
+
}
|
|
1383
|
+
if (!rt.permissions)
|
|
1384
|
+
throw usage(
|
|
1385
|
+
`${where}: ${rt.id} can't take a permission mode from Den yet`,
|
|
1386
|
+
);
|
|
1387
|
+
if (permission !== intentOf(persona.permissionMode))
|
|
1388
|
+
overrides.push("permission");
|
|
1389
|
+
}
|
|
1390
|
+
let extras = persona.allowedCommands;
|
|
1391
|
+
if (req.allow !== undefined) {
|
|
1392
|
+
try {
|
|
1393
|
+
extras = checkCommands(req.allow, "allow");
|
|
1394
|
+
} catch (e) {
|
|
1395
|
+
throw usage(`${where}: ${(e as Error).message}`);
|
|
1396
|
+
}
|
|
1397
|
+
if (extras.length && !rt.permissions)
|
|
1398
|
+
throw usage(
|
|
1399
|
+
`${where}: ${rt.id} can't take allowed commands from Den yet`,
|
|
1400
|
+
);
|
|
1401
|
+
const same =
|
|
1402
|
+
extras.length === persona.allowedCommands.length &&
|
|
1403
|
+
extras.every((c) => persona.allowedCommands.includes(c));
|
|
1404
|
+
if (!same) overrides.push("allow");
|
|
1405
|
+
}
|
|
1406
|
+
return {
|
|
1407
|
+
...(model ? { model } : {}),
|
|
1408
|
+
version,
|
|
1409
|
+
...(permission ? { permission } : {}),
|
|
1410
|
+
extras: [...extras],
|
|
1411
|
+
overrides,
|
|
1412
|
+
};
|
|
1099
1413
|
}
|
|
1100
1414
|
|
|
1101
1415
|
/** What a relaunch from handoff adds to a start (den#4). */
|
|
@@ -1127,7 +1441,8 @@ export async function startAgent(
|
|
|
1127
1441
|
syncCatalog(d);
|
|
1128
1442
|
const rt = runtimeFor(req.runtime ?? persona.runtime ?? DEFAULT_RUNTIME_ID);
|
|
1129
1443
|
const det = await availableOrRefuse(d, rt, ws);
|
|
1130
|
-
const
|
|
1444
|
+
const ch = startChoices(req, persona, rt);
|
|
1445
|
+
const model = launchModel(ch.model, ch.version);
|
|
1131
1446
|
|
|
1132
1447
|
const prev = readRecord(d.stateDir(), id);
|
|
1133
1448
|
if (prev && prev.workspace !== ws)
|
|
@@ -1135,10 +1450,21 @@ export async function startAgent(
|
|
|
1135
1450
|
`agent ${id} belongs to workspace ${prev.workspace}; give this one another --id`,
|
|
1136
1451
|
);
|
|
1137
1452
|
ptyOrRefuse(d);
|
|
1453
|
+
const allow = allowedFor(d, persona, ch.extras);
|
|
1138
1454
|
const input = {
|
|
1139
|
-
...launchInput(d, cfg, persona, ws, id, model, det.path
|
|
1455
|
+
...launchInput(d, cfg, persona, ws, id, model, det.path, undefined, {
|
|
1456
|
+
...(ch.permission ? { permission: ch.permission } : {}),
|
|
1457
|
+
...(rt.permissions ? { allow } : {}),
|
|
1458
|
+
}),
|
|
1140
1459
|
...(more.firstPrompt ? { firstPrompt: more.firstPrompt } : {}),
|
|
1141
1460
|
};
|
|
1461
|
+
// AG-13.4: a command a runtime can't express is said, never dropped.
|
|
1462
|
+
const unexpressed =
|
|
1463
|
+
!rt.permissions && allow.length
|
|
1464
|
+
? [
|
|
1465
|
+
`${rt.id} can't take allowed commands from Den yet: ${allow.join(", ")} not applied (it asks as usual)`,
|
|
1466
|
+
]
|
|
1467
|
+
: [];
|
|
1142
1468
|
|
|
1143
1469
|
return withClient(d, async (c) => {
|
|
1144
1470
|
const sessions = await c.list();
|
|
@@ -1167,10 +1493,25 @@ export async function startAgent(
|
|
|
1167
1493
|
id,
|
|
1168
1494
|
persona: persona.id,
|
|
1169
1495
|
runtime: rt.id,
|
|
1170
|
-
...(model ? { model } : {}),
|
|
1496
|
+
...(ch.model ? { model: ch.model } : {}),
|
|
1497
|
+
...(ch.model ? { version: ch.version } : {}),
|
|
1498
|
+
...(ch.permission ? { permission: ch.permission } : {}),
|
|
1499
|
+
...(req.allow !== undefined ? { allow: ch.extras } : {}),
|
|
1500
|
+
...(ch.overrides.length ? { overrides: ch.overrides } : {}),
|
|
1171
1501
|
workspace: ws,
|
|
1172
1502
|
cwd: input.worktree,
|
|
1173
1503
|
sessionName: sessionName(d.dist, ws, id),
|
|
1504
|
+
// AG-4b: a name given to the agent is the agent's, not its
|
|
1505
|
+
// conversation's: Start fresh and Relaunch keep it. AG-10.8:
|
|
1506
|
+
// else its persona's name ("Rita").
|
|
1507
|
+
...(prev?.displayName
|
|
1508
|
+
? { displayName: prev.displayName }
|
|
1509
|
+
: persona.displayName
|
|
1510
|
+
? { displayName: persona.displayName }
|
|
1511
|
+
: {}),
|
|
1512
|
+
...(prev?.displayName && prev.displayNameAt
|
|
1513
|
+
? { displayNameAt: prev.displayNameAt }
|
|
1514
|
+
: {}),
|
|
1174
1515
|
// 0.4.8: launched on this token, so a resume continues it.
|
|
1175
1516
|
...(out.resumeToken
|
|
1176
1517
|
? { resumeToken: out.resumeToken, launched: true }
|
|
@@ -1186,7 +1527,12 @@ export async function startAgent(
|
|
|
1186
1527
|
more.rotations?.length ? { rotations: more.rotations } : {},
|
|
1187
1528
|
);
|
|
1188
1529
|
const early = await exitedAtOnce(d, c, info);
|
|
1189
|
-
return {
|
|
1530
|
+
return {
|
|
1531
|
+
rec,
|
|
1532
|
+
info,
|
|
1533
|
+
warnings: [...(out.warnings ?? []), ...unexpressed],
|
|
1534
|
+
early,
|
|
1535
|
+
};
|
|
1190
1536
|
});
|
|
1191
1537
|
}
|
|
1192
1538
|
|
|
@@ -1212,6 +1558,37 @@ export function agentRecord(
|
|
|
1212
1558
|
return rec;
|
|
1213
1559
|
}
|
|
1214
1560
|
|
|
1561
|
+
/**
|
|
1562
|
+
* Name agent `id` (AG-4b: a person double-clicked its tab): `name` as
|
|
1563
|
+
* checkDisplayName takes it; null or blank clears it. The id never
|
|
1564
|
+
* changes. A usage error (400) when the name isn't one.
|
|
1565
|
+
*/
|
|
1566
|
+
export function renameAgent(
|
|
1567
|
+
d: AgentsResolved,
|
|
1568
|
+
id: string,
|
|
1569
|
+
ws: string,
|
|
1570
|
+
name: unknown,
|
|
1571
|
+
): AgentRecord {
|
|
1572
|
+
const rec = agentRecord(d, id, ws);
|
|
1573
|
+
let checked: string | null;
|
|
1574
|
+
try {
|
|
1575
|
+
checked = checkDisplayName(name);
|
|
1576
|
+
} catch (e) {
|
|
1577
|
+
throw usage((e as Error).message);
|
|
1578
|
+
}
|
|
1579
|
+
const next: AgentRecord = { ...rec };
|
|
1580
|
+
if (checked) {
|
|
1581
|
+
next.displayName = checked;
|
|
1582
|
+
// AG-4b: the latest rename wins, Den's or the runtime's.
|
|
1583
|
+
next.displayNameAt = new Date().toISOString();
|
|
1584
|
+
} else {
|
|
1585
|
+
delete next.displayName;
|
|
1586
|
+
delete next.displayNameAt;
|
|
1587
|
+
}
|
|
1588
|
+
writeRecord(d.stateDir(), next);
|
|
1589
|
+
return next;
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1215
1592
|
export interface Stopped {
|
|
1216
1593
|
rec: AgentRecord;
|
|
1217
1594
|
/** The session it sent TERM to; undefined when it wasn't running. */
|
|
@@ -1328,9 +1705,21 @@ export async function resumeAgent(
|
|
|
1328
1705
|
persona,
|
|
1329
1706
|
rec.workspace,
|
|
1330
1707
|
rec.id,
|
|
1331
|
-
rec.model,
|
|
1708
|
+
launchModel(rec.model, rec.version),
|
|
1332
1709
|
det.path,
|
|
1333
1710
|
grants,
|
|
1711
|
+
{
|
|
1712
|
+
...(rec.permission ? { permission: rec.permission } : {}),
|
|
1713
|
+
...(rt.permissions
|
|
1714
|
+
? {
|
|
1715
|
+
allow: allowedFor(
|
|
1716
|
+
d,
|
|
1717
|
+
persona,
|
|
1718
|
+
rec.allow ?? persona.allowedCommands,
|
|
1719
|
+
),
|
|
1720
|
+
}
|
|
1721
|
+
: {}),
|
|
1722
|
+
},
|
|
1334
1723
|
);
|
|
1335
1724
|
// Resume in the cwd the agent had, wherever the workspace points now.
|
|
1336
1725
|
const at = {
|
|
@@ -1439,11 +1828,24 @@ export async function startFresh(
|
|
|
1439
1828
|
workspace: rec.workspace,
|
|
1440
1829
|
persona: rec.persona,
|
|
1441
1830
|
runtime: rec.runtime,
|
|
1442
|
-
...(rec
|
|
1831
|
+
...keptChoices(rec),
|
|
1443
1832
|
id: rec.id,
|
|
1444
1833
|
});
|
|
1445
1834
|
}
|
|
1446
1835
|
|
|
1836
|
+
/**
|
|
1837
|
+
* What a record was started with (AG-10.9), for Start fresh and Relaunch:
|
|
1838
|
+
* the same model, version, permission and extra commands.
|
|
1839
|
+
*/
|
|
1840
|
+
function keptChoices(rec: AgentRecord): Partial<StartRequest> {
|
|
1841
|
+
return {
|
|
1842
|
+
...(rec.model ? { model: rec.model } : {}),
|
|
1843
|
+
...(rec.version ? { version: rec.version } : {}),
|
|
1844
|
+
...(rec.permission ? { permission: rec.permission } : {}),
|
|
1845
|
+
...(rec.allow ? { allow: [...rec.allow] } : {}),
|
|
1846
|
+
};
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1447
1849
|
// ── den#4: relaunch from handoff ────────────────────────────────────────────
|
|
1448
1850
|
|
|
1449
1851
|
export interface Relaunched extends Started {
|
|
@@ -1524,7 +1926,7 @@ export async function relaunchAgent(
|
|
|
1524
1926
|
workspace: rec.workspace,
|
|
1525
1927
|
persona: rec.persona,
|
|
1526
1928
|
runtime: rec.runtime,
|
|
1527
|
-
...(rec
|
|
1929
|
+
...keptChoices(rec),
|
|
1528
1930
|
id: rec.id,
|
|
1529
1931
|
},
|
|
1530
1932
|
{
|
|
@@ -1751,3 +2153,168 @@ export async function resumeInterrupted(
|
|
|
1751
2153
|
}
|
|
1752
2154
|
return { resumed, failed };
|
|
1753
2155
|
}
|
|
2156
|
+
|
|
2157
|
+
// ── AG-10.1: the first instruction, a plain session ─────────────────────────
|
|
2158
|
+
|
|
2159
|
+
/** The longest first instruction. */
|
|
2160
|
+
export const MAX_INSTRUCTION = 8000;
|
|
2161
|
+
|
|
2162
|
+
/**
|
|
2163
|
+
* A first instruction, checked: text of up to MAX_INSTRUCTION characters,
|
|
2164
|
+
* newlines and tabs allowed, no other control character (no escape
|
|
2165
|
+
* sequence can ride along) and no bidi override. Blank is none (null).
|
|
2166
|
+
*/
|
|
2167
|
+
export function checkInstruction(v: unknown): string | null {
|
|
2168
|
+
if (v === undefined || v === null) return null;
|
|
2169
|
+
if (typeof v !== "string") throw usage("instruction must be a string");
|
|
2170
|
+
const t = v.replace(/\r\n?/g, "\n").trim();
|
|
2171
|
+
if (!t) return null;
|
|
2172
|
+
if (t.length > MAX_INSTRUCTION)
|
|
2173
|
+
throw usage(`instruction must be at most ${MAX_INSTRUCTION} characters`);
|
|
2174
|
+
// eslint-disable-next-line no-control-regex
|
|
2175
|
+
if (/[\x00-\x08\x0b-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/.test(t))
|
|
2176
|
+
throw usage("instruction can't have control characters");
|
|
2177
|
+
return t;
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
/** How long the session must print nothing before it counts as ready. */
|
|
2181
|
+
export const READY_QUIET_MS = 800;
|
|
2182
|
+
/** The longest a first instruction waits for the session to be ready. */
|
|
2183
|
+
export const READY_MAX_MS = 30_000;
|
|
2184
|
+
const READY_POLL_MS = 150;
|
|
2185
|
+
const PASTE_ON = "\x1b[?2004h";
|
|
2186
|
+
const PASTE_OFF = "\x1b[?2004l";
|
|
2187
|
+
|
|
2188
|
+
/**
|
|
2189
|
+
* The bytes a first instruction is typed as: a bracketed paste when the
|
|
2190
|
+
* program turned bracketed paste on (its output has ESC[?2004h, last), so
|
|
2191
|
+
* newlines stay newlines; else each newline as the runtime's soft newline
|
|
2192
|
+
* (catalog softNewline). Then Enter, separately.
|
|
2193
|
+
*/
|
|
2194
|
+
export function instructionBytes(
|
|
2195
|
+
text: string,
|
|
2196
|
+
said: string,
|
|
2197
|
+
softNewline: string,
|
|
2198
|
+
): { body: string; enter: string } {
|
|
2199
|
+
const paste = said.lastIndexOf(PASTE_ON) > said.lastIndexOf(PASTE_OFF);
|
|
2200
|
+
return {
|
|
2201
|
+
body: paste
|
|
2202
|
+
? `\x1b[200~${text}\x1b[201~`
|
|
2203
|
+
: text.split("\n").join(softNewline),
|
|
2204
|
+
enter: "\r",
|
|
2205
|
+
};
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
/**
|
|
2209
|
+
* Send an agent its first instruction once it is ready (AG-10.1): it has
|
|
2210
|
+
* printed something and then nothing for READY_QUIET_MS (a TUI drawn and
|
|
2211
|
+
* waiting), or READY_MAX_MS passed. Typed into its session over the host's
|
|
2212
|
+
* write path (the same as a keystroke from the pane), never as an argument
|
|
2213
|
+
* or through a shell. False when the session ended first or there is no
|
|
2214
|
+
* host. Never throws.
|
|
2215
|
+
*/
|
|
2216
|
+
export async function sendFirstInstruction(
|
|
2217
|
+
d: AgentsResolved,
|
|
2218
|
+
sessionId: string,
|
|
2219
|
+
runtime: string,
|
|
2220
|
+
text: string,
|
|
2221
|
+
o: { quietMs?: number; maxMs?: number } = {},
|
|
2222
|
+
): Promise<boolean> {
|
|
2223
|
+
const quiet = o.quietMs ?? READY_QUIET_MS;
|
|
2224
|
+
const max = o.maxMs ?? READY_MAX_MS;
|
|
2225
|
+
let c: HostClient | null = null;
|
|
2226
|
+
try {
|
|
2227
|
+
c = await d.connectExisting();
|
|
2228
|
+
if (!c) return false;
|
|
2229
|
+
const deadline = Date.now() + max;
|
|
2230
|
+
let last = "";
|
|
2231
|
+
let changedAt = Date.now();
|
|
2232
|
+
let said = "";
|
|
2233
|
+
for (;;) {
|
|
2234
|
+
const s = (await c.list()).find((x) => x.ref.id === sessionId);
|
|
2235
|
+
if (!s || s.exited) return false;
|
|
2236
|
+
said = (await c.scrollback(sessionId, 256 * 1024)).toString("utf-8");
|
|
2237
|
+
if (said !== last) {
|
|
2238
|
+
last = said;
|
|
2239
|
+
changedAt = Date.now();
|
|
2240
|
+
} else if (said && Date.now() - changedAt >= quiet) break;
|
|
2241
|
+
if (Date.now() >= deadline) break;
|
|
2242
|
+
await new Promise((r) => setTimeout(r, READY_POLL_MS));
|
|
2243
|
+
}
|
|
2244
|
+
const soft = softNewlineSeq(getRuntime(runtime)?.catalog);
|
|
2245
|
+
const b = instructionBytes(text, said, soft);
|
|
2246
|
+
c.write(sessionId, Buffer.from(b.body, "utf-8"));
|
|
2247
|
+
await new Promise((r) => setTimeout(r, 120));
|
|
2248
|
+
c.write(sessionId, Buffer.from(b.enter, "utf-8"));
|
|
2249
|
+
// Let the host take the bytes before the connection closes.
|
|
2250
|
+
await c.list();
|
|
2251
|
+
return true;
|
|
2252
|
+
} catch {
|
|
2253
|
+
return false;
|
|
2254
|
+
} finally {
|
|
2255
|
+
c?.close();
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
/** A plain session started from the page (AG-10.1 "Plain session"). */
|
|
2260
|
+
export interface PlainStarted {
|
|
2261
|
+
info: SessionInfo;
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
/**
|
|
2265
|
+
* Plain session (AG-10.1): the person's login shell ($SHELL when it is an
|
|
2266
|
+
* absolute path to an executable, else /bin/sh) in the workspace's folder,
|
|
2267
|
+
* named shell, shell-2, … in that workspace. Its env is a plain session's
|
|
2268
|
+
* (the allowlist, every runtime's scrubEnv); it counts against
|
|
2269
|
+
* sessions.max_concurrent, never the agents' cap.
|
|
2270
|
+
*/
|
|
2271
|
+
export async function startPlainSession(
|
|
2272
|
+
d: AgentsResolved,
|
|
2273
|
+
ws: string,
|
|
2274
|
+
): Promise<PlainStarted> {
|
|
2275
|
+
checkWorkspaceName("sessions start", ws);
|
|
2276
|
+
if (!d.workspaceExists(ws))
|
|
2277
|
+
throw usage(`workspace "${ws}" does not exist`, 404);
|
|
2278
|
+
const dir = d.workspaceDir(ws);
|
|
2279
|
+
if (!fs.statSync(dir, { throwIfNoEntry: false })?.isDirectory())
|
|
2280
|
+
throw refused(`workspace ${ws}'s directory ${dir} does not exist`);
|
|
2281
|
+
ptyOrRefuse(d);
|
|
2282
|
+
const sh = d.env.SHELL;
|
|
2283
|
+
let shell = "/bin/sh";
|
|
2284
|
+
if (sh && path.isAbsolute(sh))
|
|
2285
|
+
try {
|
|
2286
|
+
fs.accessSync(sh, fs.constants.X_OK);
|
|
2287
|
+
shell = sh;
|
|
2288
|
+
} catch {
|
|
2289
|
+
/* the default */
|
|
2290
|
+
}
|
|
2291
|
+
const { plainSessionEnv } = await import("./sessions-command.js");
|
|
2292
|
+
return withClient(d, async (c) => {
|
|
2293
|
+
const names = new Set((await c.list()).map((s) => s.name));
|
|
2294
|
+
let n = 1;
|
|
2295
|
+
let name = sessionName(d.dist, ws, "shell");
|
|
2296
|
+
while (names.has(name)) name = sessionName(d.dist, ws, `shell-${++n}`);
|
|
2297
|
+
const info = await c
|
|
2298
|
+
.start(
|
|
2299
|
+
{
|
|
2300
|
+
name,
|
|
2301
|
+
cwd: dir,
|
|
2302
|
+
argv: [shell, "-l"],
|
|
2303
|
+
env: plainSessionEnv(d.env),
|
|
2304
|
+
cols: 80,
|
|
2305
|
+
rows: 24,
|
|
2306
|
+
},
|
|
2307
|
+
{ limit: d.maxSessions(), pool: "sessions" },
|
|
2308
|
+
)
|
|
2309
|
+
.catch((e: unknown) => {
|
|
2310
|
+
if (e instanceof HostError && e.code === "cap")
|
|
2311
|
+
throw new AgentError(e.message, {
|
|
2312
|
+
status: 429,
|
|
2313
|
+
code: "cap",
|
|
2314
|
+
hints: ["Stop a session first, or raise sessions.max_concurrent."],
|
|
2315
|
+
});
|
|
2316
|
+
throw e;
|
|
2317
|
+
});
|
|
2318
|
+
return { info };
|
|
2319
|
+
});
|
|
2320
|
+
}
|