@monster-orange/den 0.4.12 → 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.
Files changed (40) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +146 -10
  4. package/package.json +1 -1
  5. package/src/shared/agents/default-pack/be-dev.md +1 -0
  6. package/src/shared/agents/default-pack/devops.md +1 -0
  7. package/src/shared/agents/default-pack/fe-dev.md +1 -0
  8. package/src/shared/agents/default-pack/orchestrator.md +3 -1
  9. package/src/shared/agents/default-pack/planner.md +1 -0
  10. package/src/shared/agents/default-pack/reviewer.md +1 -0
  11. package/src/shared/agents/default-pack/tester.md +16 -0
  12. package/src/shared/agents/default-pack/ux-designer.md +1 -0
  13. package/src/shared/agents/den-claude.ts +119 -13
  14. package/src/shared/agents/permissions.ts +108 -0
  15. package/src/shared/agents/persona-store.ts +286 -0
  16. package/src/shared/agents/personas.ts +116 -12
  17. package/src/shared/agents/records.ts +28 -0
  18. package/src/shared/agents/roles.ts +288 -0
  19. package/src/shared/agents/runtime.ts +41 -0
  20. package/src/shared/den-config.ts +20 -1
  21. package/src/ui/cli/agent-ops.ts +535 -17
  22. package/src/ui/cli/agents-command.ts +12 -2
  23. package/src/ui/cli/env-probe.ts +4 -1
  24. package/src/ui/mcp/tools.ts +1 -1
  25. package/src/ui/server/agent-routes.ts +260 -21
  26. package/src/ui/server/public/agent-start.js +3 -2
  27. package/src/ui/server/public/agents-page.js +499 -48
  28. package/src/ui/server/public/agents.html +58 -15
  29. package/src/ui/server/public/i18n/es.js +120 -0
  30. package/src/ui/server/public/i18n/ja.js +120 -0
  31. package/src/ui/server/public/persona-ui.css +539 -0
  32. package/src/ui/server/public/persona-ui.js +796 -0
  33. package/src/ui/server/public/settings-page.js +4 -9
  34. package/src/ui/server/public/settings-registry.js +22 -0
  35. package/src/ui/server/public/settings.html +1 -0
  36. package/src/ui/server/public/shell.js +117 -0
  37. package/src/ui/server/public/topbar.js +2 -2
  38. package/src/ui/server/route-classes.ts +18 -0
  39. package/src/ui/server/session-routes.ts +21 -5
  40. package/src/ui/server/ui-assets.ts +3 -0
@@ -81,6 +81,31 @@ import {
81
81
  resolvePackDirs,
82
82
  type Persona,
83
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";
84
109
  import {
85
110
  AGENT_ID_RE,
86
111
  agentFilesDir,
@@ -93,6 +118,7 @@ import {
93
118
  checkDisplayName,
94
119
  writeRuntimeFiles,
95
120
  type AgentRecord,
121
+ type Override,
96
122
  } from "../../shared/agents/records.js";
97
123
  import {
98
124
  agentSessionEnv,
@@ -105,7 +131,11 @@ import {
105
131
  type LaunchInput,
106
132
  type Runtime,
107
133
  } from "../../shared/agents/runtimes.js";
108
- import type { RuntimeDetect } from "../../shared/agents/runtime.js";
134
+ import type {
135
+ RuntimeDetect,
136
+ RuntimeModel,
137
+ RuntimePermissions,
138
+ } from "../../shared/agents/runtime.js";
109
139
  import {
110
140
  archiveHandoff,
111
141
  DEFAULT_ROTATE,
@@ -360,13 +390,25 @@ export interface AgentRow {
360
390
  /** The name a person gave it (AG-4b), when one did. */
361
391
  displayName?: string;
362
392
  /**
363
- * Its session's name (AG-4b): the latest of Den's rename and the
364
- * runtime's own (Claude Code's /rename), when there is one.
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.
365
397
  */
366
- sessionName?: string;
398
+ sessionTitle?: string;
367
399
  persona?: string;
368
400
  runtime?: string;
369
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;
370
412
  workspace?: string;
371
413
  cwd?: string;
372
414
  /** running, stopping (stopped, its session not yet gone), exited
@@ -473,7 +515,7 @@ export function agentRows(
473
515
  return {
474
516
  agent: rec.id,
475
517
  ...recordFields(rec),
476
- ...(named ? { sessionName: named } : {}),
518
+ ...(named ? { sessionTitle: named } : {}),
477
519
  state: st,
478
520
  startedAt: s?.startedAt ?? rec.startedAt,
479
521
  resumable,
@@ -504,11 +546,32 @@ function recordFields(rec: AgentRecord): Partial<AgentRow> {
504
546
  persona: rec.persona,
505
547
  runtime: rec.runtime,
506
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),
507
554
  workspace: rec.workspace,
508
555
  cwd: rec.cwd,
509
556
  };
510
557
  }
511
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
+
512
575
  /**
513
576
  * Note in each record whose session the host lists as exited that it
514
577
  * ended, with its code (AG-14.1: "Exited (code n)"), so it still says so
@@ -586,6 +649,7 @@ function personas(
586
649
  resolvePackDirs(cfg.personaPacks, d.distRoot()),
587
650
  undefined,
588
651
  { defaultPack: cfg.defaultPackGuardrails, packs: cfg.packGuardrails },
652
+ localPersonasDir(d.stateDir()),
589
653
  );
590
654
  } catch (e) {
591
655
  throw refused((e as Error).message, [], 422);
@@ -593,13 +657,112 @@ function personas(
593
657
  }
594
658
 
595
659
  /** A persona as listed: everything but its role prompt. */
596
- 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
+ };
597
664
 
598
- /** Den's default pack, then agents.persona_packs, by id. */
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. */
599
672
  export function listPersonas(d: AgentsResolved): ListedPersona[] {
600
673
  return [...personas(d, agentsConfig(d)).values()]
601
674
  .sort((a, b) => a.id.localeCompare(b.id))
602
- .map(({ prompt: _p, guardrailsSet: _g, ...rest }) => rest);
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
+ );
603
766
  }
604
767
 
605
768
  export interface RuntimeChoice {
@@ -633,6 +796,12 @@ export interface RuntimeChoice {
633
796
  /** It declares resume. */
634
797
  resume: boolean;
635
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;
636
805
  }
637
806
 
638
807
  /**
@@ -761,6 +930,8 @@ export async function listRuntimes(
761
930
  : {}),
762
931
  resume: Boolean(rt.resume),
763
932
  default: id === DEFAULT_RUNTIME_ID,
933
+ models: rt.models?.() ?? [],
934
+ ...(rt.permissions ? { permissions: rt.permissions } : {}),
764
935
  };
765
936
  }),
766
937
  );
@@ -1037,6 +1208,8 @@ function launchInput(
1037
1208
  bin?: string,
1038
1209
  /** The grants to serve (a resume's, narrowed); default the config's. */
1039
1210
  only?: readonly string[],
1211
+ /** AG-13: the intent and the allowed commands (preset and extras). */
1212
+ perms: { permission?: PermissionIntent; allow?: string[] } = {},
1040
1213
  ): LaunchInput {
1041
1214
  const grants = only ? [...only] : (cfg.agents[id]?.grants ?? []);
1042
1215
  for (const g of grants)
@@ -1065,9 +1238,32 @@ function launchInput(
1065
1238
  env: d.env,
1066
1239
  ...(bin ? { bin } : {}),
1067
1240
  rotateAfter: after,
1241
+ ...(perms.permission ? { permission: perms.permission } : {}),
1242
+ ...(perms.allow?.length ? { allow: [...perms.allow] } : {}),
1068
1243
  };
1069
1244
  }
1070
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
+
1071
1267
  /** The rotation limit a launch counts against: 0 when its runtime can't relaunch. */
1072
1268
  function launchAfter(rt: Runtime, input: LaunchInput): number {
1073
1269
  return rt.firstPrompt ? (input.rotateAfter ?? 0) : 0;
@@ -1108,6 +1304,112 @@ export interface StartRequest {
1108
1304
  runtime?: string;
1109
1305
  model?: string;
1110
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
+ };
1111
1413
  }
1112
1414
 
1113
1415
  /** What a relaunch from handoff adds to a start (den#4). */
@@ -1139,7 +1441,8 @@ export async function startAgent(
1139
1441
  syncCatalog(d);
1140
1442
  const rt = runtimeFor(req.runtime ?? persona.runtime ?? DEFAULT_RUNTIME_ID);
1141
1443
  const det = await availableOrRefuse(d, rt, ws);
1142
- const model = req.model ?? persona.model;
1444
+ const ch = startChoices(req, persona, rt);
1445
+ const model = launchModel(ch.model, ch.version);
1143
1446
 
1144
1447
  const prev = readRecord(d.stateDir(), id);
1145
1448
  if (prev && prev.workspace !== ws)
@@ -1147,10 +1450,21 @@ export async function startAgent(
1147
1450
  `agent ${id} belongs to workspace ${prev.workspace}; give this one another --id`,
1148
1451
  );
1149
1452
  ptyOrRefuse(d);
1453
+ const allow = allowedFor(d, persona, ch.extras);
1150
1454
  const input = {
1151
- ...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
+ }),
1152
1459
  ...(more.firstPrompt ? { firstPrompt: more.firstPrompt } : {}),
1153
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
+ : [];
1154
1468
 
1155
1469
  return withClient(d, async (c) => {
1156
1470
  const sessions = await c.list();
@@ -1179,13 +1493,22 @@ export async function startAgent(
1179
1493
  id,
1180
1494
  persona: persona.id,
1181
1495
  runtime: rt.id,
1182
- ...(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 } : {}),
1183
1501
  workspace: ws,
1184
1502
  cwd: input.worktree,
1185
1503
  sessionName: sessionName(d.dist, ws, id),
1186
1504
  // AG-4b: a name given to the agent is the agent's, not its
1187
- // conversation's: Start fresh and Relaunch keep it.
1188
- ...(prev?.displayName ? { displayName: prev.displayName } : {}),
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
+ : {}),
1189
1512
  ...(prev?.displayName && prev.displayNameAt
1190
1513
  ? { displayNameAt: prev.displayNameAt }
1191
1514
  : {}),
@@ -1204,7 +1527,12 @@ export async function startAgent(
1204
1527
  more.rotations?.length ? { rotations: more.rotations } : {},
1205
1528
  );
1206
1529
  const early = await exitedAtOnce(d, c, info);
1207
- return { rec, info, warnings: out.warnings ?? [], early };
1530
+ return {
1531
+ rec,
1532
+ info,
1533
+ warnings: [...(out.warnings ?? []), ...unexpressed],
1534
+ early,
1535
+ };
1208
1536
  });
1209
1537
  }
1210
1538
 
@@ -1377,9 +1705,21 @@ export async function resumeAgent(
1377
1705
  persona,
1378
1706
  rec.workspace,
1379
1707
  rec.id,
1380
- rec.model,
1708
+ launchModel(rec.model, rec.version),
1381
1709
  det.path,
1382
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
+ },
1383
1723
  );
1384
1724
  // Resume in the cwd the agent had, wherever the workspace points now.
1385
1725
  const at = {
@@ -1488,11 +1828,24 @@ export async function startFresh(
1488
1828
  workspace: rec.workspace,
1489
1829
  persona: rec.persona,
1490
1830
  runtime: rec.runtime,
1491
- ...(rec.model ? { model: rec.model } : {}),
1831
+ ...keptChoices(rec),
1492
1832
  id: rec.id,
1493
1833
  });
1494
1834
  }
1495
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
+
1496
1849
  // ── den#4: relaunch from handoff ────────────────────────────────────────────
1497
1850
 
1498
1851
  export interface Relaunched extends Started {
@@ -1573,7 +1926,7 @@ export async function relaunchAgent(
1573
1926
  workspace: rec.workspace,
1574
1927
  persona: rec.persona,
1575
1928
  runtime: rec.runtime,
1576
- ...(rec.model ? { model: rec.model } : {}),
1929
+ ...keptChoices(rec),
1577
1930
  id: rec.id,
1578
1931
  },
1579
1932
  {
@@ -1800,3 +2153,168 @@ export async function resumeInterrupted(
1800
2153
  }
1801
2154
  return { resumed, failed };
1802
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
+ }