crewforth 3.1.0-rc.4 → 3.1.0-rc.6

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 (58) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +1 -1
  3. package/kit/AGENT_TEMPLATE.md +48 -19
  4. package/kit/CLAUDE.md +13 -0
  5. package/kit/agents/crew-backend-expert.md +8 -0
  6. package/kit/agents/crew-commit-agent.md +8 -0
  7. package/kit/agents/crew-database-expert.md +8 -0
  8. package/kit/agents/crew-devops-expert.md +8 -0
  9. package/kit/agents/crew-frontend-expert.md +8 -0
  10. package/kit/agents/crew-performance-expert.md +8 -0
  11. package/kit/agents/crew-planner.md +14 -0
  12. package/kit/agents/crew-privacy-agent.md +10 -2
  13. package/kit/agents/crew-review-agent.md +8 -0
  14. package/kit/agents/crew-security-expert.md +11 -4
  15. package/kit/agents/crew-session-manager.md +10 -3
  16. package/kit/agents/crew-test-expert.md +8 -0
  17. package/kit/eval/doctor.sh +16 -0
  18. package/kit/eval/smoke-test.sh +898 -11
  19. package/kit/hooks/agent-outcome.sh +416 -0
  20. package/kit/hooks/guard-agent-model.sh +819 -0
  21. package/kit/hooks/guard-bash.sh +26 -1
  22. package/kit/hooks/guard-schedule.sh +7 -2
  23. package/kit/hooks/guard-write.sh +31 -0
  24. package/kit/hooks/prompt-approval.sh +55 -5
  25. package/kit/settings.json +54 -0
  26. package/kit/skills/crew-loosen/SKILL.md +28 -0
  27. package/kit/studio/server/hooks/studio-gate.sh +90 -4
  28. package/kit/studio/server/hooks/studio-host.mjs +92 -0
  29. package/kit/studio/server/index.js +86 -7
  30. package/kit/studio/server/lib/agent-origin.js +44 -0
  31. package/kit/studio/server/lib/graph.js +142 -10
  32. package/kit/studio/server/lib/model-outcomes.js +287 -0
  33. package/kit/studio/server/lib/palette.js +5 -18
  34. package/kit/studio/server/lib/permissions.js +260 -17
  35. package/kit/studio/server/lib/session.js +41 -1
  36. package/kit/studio/server/lib/usage.js +103 -25
  37. package/kit/studio/web/app.js +251 -37
  38. package/kit/studio/web/approvals.js +53 -2
  39. package/kit/studio/web/ask.js +137 -0
  40. package/kit/studio/web/canvas.js +220 -59
  41. package/kit/studio/web/chat.js +19 -2
  42. package/kit/studio/web/convo.js +14 -1
  43. package/kit/studio/web/dock.js +30 -9
  44. package/kit/studio/web/graph-plan.js +66 -18
  45. package/kit/studio/web/icons/builtin.svg +1 -1
  46. package/kit/studio/web/index.html +11 -4
  47. package/kit/studio/web/inspect.js +33 -1
  48. package/kit/studio/web/keys.js +29 -0
  49. package/kit/studio/web/list-plan.js +8 -3
  50. package/kit/studio/web/list.js +35 -7
  51. package/kit/studio/web/models-plan.js +167 -0
  52. package/kit/studio/web/models.js +127 -0
  53. package/kit/studio/web/names.js +84 -0
  54. package/kit/studio/web/style.css +134 -15
  55. package/kit/studio/web/timeline-plan.js +8 -0
  56. package/kit/studio/web/timeline.js +36 -10
  57. package/kit/studio/web/usage.js +53 -12
  58. package/package.json +1 -1
@@ -434,6 +434,8 @@ mark { background: color-mix(in srgb, var(--studio-accent) 30%, transparent); co
434
434
  font-size: 13px; color: var(--ink); overflow-wrap: anywhere;
435
435
  }
436
436
  .toast[hidden] { display: none; }
437
+ /* The one action a toast can carry. The toast takes clicks only where that button is. */
438
+ .toast-act { pointer-events: auto; border: 0; padding: 0; background: none; font: inherit; color: var(--studio-accent); cursor: pointer; text-decoration: underline; }
437
439
 
438
440
  /* ============================================================== stage === */
439
441
 
@@ -545,6 +547,56 @@ mark { background: color-mix(in srgb, var(--studio-accent) 30%, transparent); co
545
547
 
546
548
  /* ------------------------------------------------------------ the list ---
547
549
  One column, in the order of what needs the reader. */
550
+ /* ------------------------------------------------------------- models ---
551
+ The fourth view: the project's record of which model each kind of task needs. One pane, a table that scrolls
552
+ sideways in a narrow window, and under it what could be lowered. */
553
+ .mv { flex: 1; min-height: 0; min-width: 0; overflow: auto; background: var(--ground); padding: var(--space-4); }
554
+ .mv[hidden] { display: none; }
555
+ .mv-empty { padding: var(--space-6); color: var(--ink-dim); font-size: 13px; }
556
+ .mv-pane { border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--surface); overflow: hidden; }
557
+ .mv-head { display: flex; align-items: center; gap: var(--space-2); padding: 14px 18px; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
558
+ .mv-title { margin: 0; font-size: 15px; font-weight: 700; }
559
+ .mv .sub { white-space: normal; flex: initial; min-width: 0; }
560
+ .mv-scroll { overflow-x: auto; }
561
+ .mv-table { border-collapse: collapse; width: 100%; font-size: 13px; }
562
+ .mv-table th { font-size: 11px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-dim); text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--line); white-space: nowrap; }
563
+ .mv-table td { padding: 10px 12px; border-bottom: 1px solid var(--line); white-space: nowrap; }
564
+ .mv-table tbody tr:last-child td { border-bottom: 0; }
565
+ .mv-table .mv-num { text-align: right; font-variant-numeric: tabular-nums; }
566
+ .mv-table td.mv-num { font-family: var(--font-mono); }
567
+ .mv-tag { display: inline-flex; align-items: center; height: 20px; padding: 0 8px; border-radius: var(--radius-sm); font-size: 11px; font-weight: 700; white-space: nowrap; background: var(--surface-raised); color: var(--ink-dim); }
568
+ .mv-tag[data-risk="critical"] { color: var(--status-fail); background: color-mix(in srgb, var(--status-fail) 14%, transparent); }
569
+ .mv-tag[data-kind="raised"] { margin-left: 6px; color: var(--status-waiting); background: color-mix(in srgb, var(--status-waiting) 14%, transparent); }
570
+ .mv-tag[data-kind="lowered"] { margin-left: 6px; color: var(--studio-accent); background: color-mix(in srgb, var(--studio-accent) 16%, transparent); }
571
+ .mv-raised .mv-tag[data-kind="lowered"] { margin-left: 0; }
572
+ .mv-tag[data-tone="pass"] { color: var(--status-good); background: color-mix(in srgb, var(--status-good) 14%, transparent); }
573
+ .mv-tag[data-tone="fail"] { color: var(--status-fail); background: color-mix(in srgb, var(--status-fail) 14%, transparent); }
574
+ .mv-tag[data-kind="escalated"] { color: var(--studio-accent); background: color-mix(in srgb, var(--studio-accent) 16%, transparent); }
575
+ .mv-table td[data-partial="true"] { color: var(--ink-dim); }
576
+ .mv-meter { display: inline-block; width: 72px; height: 6px; margin-left: 8px; border-radius: 999px; background: var(--surface-raised); overflow: hidden; vertical-align: middle; }
577
+ .mv-meter-fill { display: block; height: 6px; }
578
+ .mv-meter-fill[data-tone="good"] { background: var(--status-good); }
579
+ .mv-meter-fill[data-tone="waiting"] { background: var(--status-waiting); }
580
+ .mv-suggest { margin: 16px 18px; padding: 14px 16px; display: flex; flex-direction: column; gap: 8px; border: 1px solid color-mix(in srgb, var(--studio-accent) 45%, var(--line)); border-radius: var(--radius-md); background: color-mix(in srgb, var(--studio-accent) 9%, var(--surface)); }
581
+ .mv-suggest-h { margin: 0; font-size: 11px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--studio-accent); }
582
+ .mv-suggest-text { margin: 0; font-size: 14px; line-height: 21px; }
583
+ .mv-suggest-row { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; }
584
+ .mv-cmd { padding: 2px 6px; border-radius: var(--radius-sm); background: var(--surface-raised); font-size: 12px; user-select: all; }
585
+ .mv-raised { display: flex; align-items: baseline; gap: var(--space-2); padding: 0 18px 16px; }
586
+ .mv-raised .mv-tag { margin-left: 0; flex: none; }
587
+
588
+ /* The inspector's task card: the three lines as they were written, and under them how the work went. */
589
+ .icard { margin: 0; display: grid; grid-template-columns: 64px minmax(0, 1fr); gap: 4px 12px; font-size: 12px; line-height: 18px; }
590
+ .icard dt { color: var(--ink-faint); }
591
+ .icard dd { margin: 0; font-family: var(--font-mono); overflow-wrap: anywhere; }
592
+ .isec-h .mv-tag { float: right; text-transform: none; letter-spacing: 0; }
593
+ .isteps { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 6px; font-size: 12px; line-height: 20px; }
594
+ .isteps li { display: flex; gap: 10px; align-items: flex-start; }
595
+ .istep-n { flex: none; width: 20px; height: 20px; border-radius: 999px; background: var(--surface-raised); color: var(--ink-dim); font-size: 11px; font-weight: 700; display: inline-flex; align-items: center; justify-content: center; }
596
+ .istep-says { min-width: 0; overflow-wrap: anywhere; }
597
+ .istep-says .mv-tag { margin-left: 6px; }
598
+ .istep-note { margin: 8px 0 0; font-size: 12px; line-height: 16px; color: var(--ink-dim); }
599
+
548
600
  .ls { flex: 1; min-height: 0; min-width: 0; overflow-y: auto; background: var(--ground); padding: var(--space-3) var(--space-4) var(--space-6); }
549
601
  .ls[hidden] { display: none; }
550
602
  .ls-sec { max-width: 760px; margin: 0 auto var(--space-4); }
@@ -742,6 +794,37 @@ button.tl-lab { cursor: pointer; }
742
794
  }
743
795
  .terminal-wait-note { flex: none; margin-left: auto; }
744
796
 
797
+ /* A question the model asks, or a plan it wants approved: a form in the dock, or on a card in the List. */
798
+ .ask { display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; margin-top: var(--space-1); }
799
+ .ask-q { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
800
+ .ask-head { display: flex; align-items: baseline; gap: var(--space-2); min-width: 0; flex-wrap: wrap; }
801
+ .ask-tag { flex: none; padding: 0 6px; border-radius: var(--radius-sm); background: var(--surface-raised); font-size: 11px; color: var(--ink-dim); }
802
+ .ask-text { font-size: 13px; color: var(--ink); overflow-wrap: anywhere; }
803
+ .ask-opts { display: flex; flex-wrap: wrap; gap: 6px; }
804
+ .ask-opt {
805
+ height: 28px; padding: 0 10px; border-radius: var(--radius-sm); border: 1px solid var(--line);
806
+ background: var(--surface); color: var(--ink); font: inherit; font-size: 12px; cursor: pointer;
807
+ }
808
+ .ask-opt:hover { border-color: var(--ink-faint); }
809
+ .ask-opt[aria-checked="true"] { border-color: var(--studio-accent); box-shadow: 0 0 0 1px var(--studio-accent); }
810
+ .ask-opt:disabled, .ask-own:disabled { opacity: 0.5; cursor: default; }
811
+ .ask-own {
812
+ height: 28px; padding: 0 8px; min-width: 0; max-width: 360px; border-radius: var(--radius-sm);
813
+ border: 1px solid var(--line); background: var(--ground); color: var(--ink); font: inherit; font-size: 12px;
814
+ }
815
+ .ask-plan {
816
+ margin: 0; padding: var(--space-2) var(--space-3); max-height: 220px; overflow: auto;
817
+ border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--ground);
818
+ font-family: var(--font-mono); font-size: 12px; line-height: 1.5; color: var(--ink); white-space: pre-wrap; overflow-wrap: anywhere;
819
+ }
820
+ .ask-acts { display: flex; flex-wrap: wrap; gap: var(--space-2); justify-content: flex-end; }
821
+ .dock-ask[hidden] { display: none; }
822
+ /* The mode in the conversation's strip is a control: it looks like the text it sits among until it is hovered. */
823
+ .pane-mode { border: 0; padding: 0 4px; margin: 0 -4px; border-radius: var(--radius-sm); background: none; font: inherit; color: inherit; cursor: pointer; text-decoration: underline dotted; text-underline-offset: 3px; }
824
+ .pane-mode:hover { background: var(--surface-raised); color: var(--ink); }
825
+ /* With a form in it the dock is as tall as the form needs, and the countdown sits at its top. */
826
+ .dock[data-ask="questions"], .dock[data-ask="plan"] { align-items: start; }
827
+
745
828
  /* -------------------------------------------------------------- dock ---
746
829
  The approval dock: a row under the canvas, there only while a tool call is
747
830
  waiting. It is part of the stage's column, so the canvas above it stays
@@ -846,6 +929,27 @@ button.tl-lab { cursor: pointer; }
846
929
  .cv-edge-label { font-size: 10px; fill: var(--ink-faint); pointer-events: none; }
847
930
  /* "#3 · 09:14": where a card stands in the order the session called its agents. */
848
931
  .cv-under { display: flex; align-items: baseline; min-width: 0; }
932
+ /* The model a card ran on: a small badge, whole. What follows it gives way. */
933
+ .cv-model {
934
+ flex: none; padding: 0 5px; border-radius: var(--radius-sm); background: var(--surface-raised);
935
+ font-size: 10px; line-height: 16px; color: var(--ink-dim); white-space: nowrap;
936
+ }
937
+ .cv-model[hidden] { display: none; }
938
+ .cv-model[data-differs="true"] { color: var(--status-waiting); }
939
+ .cv-third { gap: 6px; }
940
+ .cv-facts { flex: 1 1 auto; min-width: 0; font-size: 11px; line-height: 16px; color: var(--ink-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }
941
+ .cv-totals { font-variant-numeric: tabular-nums; }
942
+ /* The same badge outside the graph: a Timeline row, a List row, a delegation card. */
943
+ .tl-model, .ls-model, .deleg-model {
944
+ flex: none; padding: 0 5px; margin-right: 6px; border-radius: var(--radius-sm); background: var(--surface-raised);
945
+ font-size: 10px; line-height: 16px; color: var(--ink-dim); white-space: nowrap;
946
+ }
947
+ .tl-model[hidden] { display: none; }
948
+ .imodel { margin: 0; display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; font-size: 12px; }
949
+ .imodel dt { color: var(--ink-faint); }
950
+ .imodel dd { margin: 0; word-break: break-word; }
951
+ .ilink { border: 0; padding: 0; background: none; font: inherit; color: var(--studio-accent); cursor: pointer; text-decoration: underline; }
952
+ .cv-mix { color: var(--ink-faint); }
849
953
  .cv-under .cv-task { flex: 1 1 auto; min-width: 0; }
850
954
  .cv-order { flex: none; margin-left: 6px; font-family: var(--font-mono); font-size: 10px; color: var(--ink-faint); font-variant-numeric: tabular-nums; white-space: nowrap; }
851
955
  .cv-order[hidden] { display: none; }
@@ -961,7 +1065,7 @@ button.tl-lab { cursor: pointer; }
961
1065
  .cv-row { display: flex; align-items: center; gap: var(--space-2); min-width: 0; }
962
1066
  .cv-fill { flex: 1; min-width: 0; }
963
1067
  .cv-indent { padding-left: 32px; }
964
- .cv-title { font-size: 13px; font-weight: 600; }
1068
+ .cv-title { font-size: 13px; font-weight: 600; flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
965
1069
  .cv-name {
966
1070
  min-width: 0; font-family: var(--font-mono); font-size: 12px; line-height: 16px; color: var(--ink);
967
1071
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
@@ -970,7 +1074,12 @@ button.tl-lab { cursor: pointer; }
970
1074
  min-width: 0; font-size: 13px; line-height: 18px; color: var(--ink-dim);
971
1075
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
972
1076
  }
973
- .cv-sub { font-size: 12px; line-height: 16px; color: var(--ink-dim); white-space: nowrap; flex: none; }
1077
+ /* Nothing a card says may run out of it. Every piece of text in a card is one line that gives way with an
1078
+ ellipsis; the pieces that must stay whole (the status pill, the tile, the order tag) are short by construction
1079
+ and do not shrink. */
1080
+ .cv-sub { font-size: 12px; line-height: 16px; color: var(--ink-dim); white-space: nowrap; flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
1081
+ .cv-sub[hidden] { display: none; }
1082
+ .cv-node > .cv-row, .cv-node > .cv-under, .cv-node > .cv-name, .cv-node > .cv-sub { max-width: 100%; }
974
1083
  .cv-word { font-size: 11px; color: var(--ink-dim); white-space: nowrap; flex: none; }
975
1084
 
976
1085
  /* The tile says whose agent it is. Crewforth's mark keeps its own colours; the
@@ -983,15 +1092,6 @@ button.tl-lab { cursor: pointer; }
983
1092
  }
984
1093
  .cv-tile svg { width: 64%; height: 64%; display: block; }
985
1094
  .cv-tile-sm { width: 18px; height: 18px; }
986
- /* A type nobody declared is marked as one. It is not quietly drawn as built-in. */
987
- .cv-tile[data-source="unknown"]::after {
988
- content: '?';
989
- position: absolute; right: -5px; top: -5px;
990
- width: 12px; height: 12px; border-radius: var(--radius-pill);
991
- display: flex; align-items: center; justify-content: center;
992
- background: var(--surface); border: 1px solid var(--line);
993
- font-family: var(--font-mono); font-size: 9px; line-height: 1; color: var(--ink-dim);
994
- }
995
1095
 
996
1096
  .cv-pill {
997
1097
  display: inline-flex; align-items: center; gap: 5px; flex: none;
@@ -1006,6 +1106,8 @@ button.tl-lab { cursor: pointer; }
1006
1106
  .cv-node.cv-chip { flex-direction: row; align-items: center; gap: var(--space-2); padding: 0 10px; border-radius: 8px; }
1007
1107
  .cv-chip .cv-name { flex: 0 1 auto; }
1008
1108
  .cv-chip .cv-task { flex: 1 1 0; font-size: 12px; }
1109
+ /* On a chip the model is said in full and does not shrink; the task beside it does. */
1110
+ .cv-chip .cv-model { flex: none; }
1009
1111
 
1010
1112
  /* The session: where everything starts. */
1011
1113
  .cv-session { cursor: pointer; }
@@ -1090,10 +1192,27 @@ button.tl-lab { cursor: pointer; }
1090
1192
  }
1091
1193
  .cv-legend[hidden] { display: none; }
1092
1194
  /* The session's summary is the same kind of box, in the opposite corner. */
1093
- .cv-spend { right: auto; bottom: auto; left: var(--space-6); top: var(--space-6); width: 220px; }
1094
- .cv-spend-rows { margin: 0; display: grid; grid-template-columns: 1fr auto; gap: var(--space-1) var(--space-3); font-size: 12px; }
1095
- .cv-spend-rows dt { color: var(--ink-dim); }
1096
- .cv-spend-rows dd { margin: 0; font-family: var(--font-mono); font-variant-numeric: tabular-nums; text-align: right; color: var(--ink); }
1195
+ .cv-spend { right: auto; bottom: auto; left: var(--space-6); top: var(--space-6); width: 240px; }
1196
+ .cv-spend-rows { display: flex; flex-direction: column; gap: 2px; font-size: 12px; min-width: 0; }
1197
+ .cv-spend-row {
1198
+ display: flex; align-items: baseline; gap: var(--space-3); min-width: 0; width: 100%;
1199
+ padding: 2px 4px; margin: 0 -4px; border: 0; border-radius: var(--radius-sm);
1200
+ background: none; font: inherit; color: inherit; text-align: left; cursor: default;
1201
+ }
1202
+ .cv-spend-label { flex: 1 1 auto; min-width: 0; color: var(--ink-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1203
+ .cv-spend-value { flex: none; font-family: var(--font-mono); font-variant-numeric: tabular-nums; color: var(--ink); white-space: nowrap; }
1204
+ /* A row that opens: it looks like something to press, and says so with a mark that turns. */
1205
+ .cv-spend-more { cursor: pointer; }
1206
+ .cv-spend-more:hover { background: var(--surface-raised); }
1207
+ .cv-spend-more .cv-spend-label::before { content: '\203A'; display: inline-block; width: 10px; color: var(--ink-faint); }
1208
+ .cv-spend-more[aria-expanded="true"] .cv-spend-label::before { rotate: 90deg; }
1209
+ .cv-spend-detail {
1210
+ margin: 0 0 var(--space-1) 10px; padding-left: var(--space-2); border-left: 1px solid var(--line);
1211
+ display: grid; grid-template-columns: 1fr auto; gap: 2px var(--space-3);
1212
+ }
1213
+ .cv-spend-detail[hidden] { display: none; }
1214
+ .cv-spend-detail dt { color: var(--ink-faint); }
1215
+ .cv-spend-detail dd { margin: 0; font-family: var(--font-mono); font-variant-numeric: tabular-nums; text-align: right; color: var(--ink-dim); }
1097
1216
  .cv-legend-head { display: flex; align-items: center; justify-content: space-between; }
1098
1217
  .cv-legend-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-1) var(--space-2); }
1099
1218
  .cv-legend-item { display: flex; align-items: center; gap: var(--space-2); font-size: 12px; color: var(--ink-dim); white-space: nowrap; }
@@ -108,6 +108,14 @@ export function lanes(bars) {
108
108
  return out[1].length ? out : [out[0]];
109
109
  }
110
110
 
111
+ /**
112
+ * Which groups are folded. A group is as the viewer left it (`hand`: id -> folded); one they have not touched
113
+ * follows the last "Expand all" (`all` true) or "Fold all" (`all` false), and before either it is open.
114
+ */
115
+ export function foldedSet(ids, hand = new Map(), all = null) {
116
+ return new Set(ids.filter((id) => (hand.has(id) ? hand.get(id) : all === false)));
117
+ }
118
+
111
119
  /** The groups of a session's agents under one grouping, in the order they first started. */
112
120
  export function groupsOf(nodes, grouping) {
113
121
  const agents = nodes.filter((n) => n.kind === 'agent');
@@ -3,10 +3,10 @@
3
3
  // Which rows there are, where a bar starts and ends and what it says is decided in timeline-plan.js. This file
4
4
  // draws that, and hands back what the viewer did: a row chosen, a group folded, the view moved.
5
5
  import {
6
- rows, groupsOf, extent, windowOf, place, ticks, clock, stepRange, waitNote, factsOf, xOf, RANGES, DEFAULT_RANGE,
6
+ rows, groupsOf, foldedSet, extent, windowOf, place, ticks, clock, stepRange, waitNote, factsOf, xOf, RANGES, DEFAULT_RANGE,
7
7
  } from './timeline-plan.js';
8
8
  import { Press } from './press.js';
9
- import { shownType, hoverOf } from './names.js';
9
+ import { shownType, hoverOf, modelOf, modelFamily } from './names.js';
10
10
 
11
11
 
12
12
  function mk(tag, cls, text) {
@@ -39,7 +39,8 @@ export class Timeline {
39
39
  this.range = DEFAULT_RANGE;
40
40
  this.follow = true;
41
41
  this.end = null; // where the view ends when it is not following
42
- this.folded = new Set();
42
+ this.fold = new Map(); // group id -> folded, the viewer's own choices
43
+ this.all = null; // true after Expand all, false after Fold all: what an untouched group is
43
44
  this.expanded = new Set();
44
45
  this.waiting = new Set();
45
46
  this.filter = null; // Set of statuses, or null
@@ -103,7 +104,8 @@ export class Timeline {
103
104
  if (id === this.session) return;
104
105
  this.session = id;
105
106
  this.nodes = [];
106
- this.folded = new Set();
107
+ this.fold = new Map(); // group id -> folded, the viewer's own choices
108
+ this.all = null; // true after Expand all, false after Fold all: what an untouched group is
107
109
  this.expanded = new Set();
108
110
  this.selected = null;
109
111
  this.follow = true;
@@ -113,9 +115,24 @@ export class Timeline {
113
115
  }
114
116
 
115
117
  setNodes(nodes) { this.nodes = nodes ?? []; this.render(); }
116
- setGroup(g) { if (g === this.group) return; this.group = g; this.folded = new Set(); this.expanded = new Set(); this.render(); }
118
+ setGroup(g) { if (g === this.group) return; this.group = g; this.fold = new Map(); this.all = null; this.expanded = new Set(); this.render(); }
119
+
120
+ /** Open every group and show every row of the long ones; a group that arrives later arrives open. */
121
+ expandAll() { this.all = true; this.fold = new Map(); this.render(); this.#changed(); }
122
+ /** Fold every group; one that arrives later arrives folded. */
123
+ foldAll() { this.all = false; this.fold = new Map(); this.expanded = new Set(); this.render(); this.#changed(); }
124
+ /** Whether there is anything left for each of the two to do. */
125
+ foldState() {
126
+ const heads = (this.lastRows ?? []).filter((r) => r.kind === 'group');
127
+ return {
128
+ groups: heads.length,
129
+ canExpand: heads.some((r) => r.folded) || (this.lastRows ?? []).some((r) => r.kind === 'more'),
130
+ canFold: heads.some((r) => !r.folded),
131
+ };
132
+ }
117
133
  setWaiting(ids) { this.waiting = new Set(ids ?? []); this.render(); }
118
134
  setFilter(statuses, { keepWaiting = false } = {}) { this.filter = statuses ? new Set(statuses) : null; this.keepWaiting = keepWaiting; this.render(); }
135
+ setModelFilter(family) { this.modelFilter = family ?? null; this.render(); }
119
136
  setOwned(summary) { this.owned = summary ?? null; this.render(); }
120
137
  setLive(live) { if (live === this.live) return; this.live = live; this.render(); this.#changed(); }
121
138
 
@@ -150,7 +167,7 @@ export class Timeline {
150
167
  if (id) {
151
168
  for (const g of groupsOf(this.nodes, this.group)) {
152
169
  if (!g.members.some((m) => m.id === id)) continue;
153
- this.folded.delete(g.id);
170
+ this.fold.set(g.id, false);
154
171
  this.expanded.add(g.id);
155
172
  }
156
173
  }
@@ -159,6 +176,7 @@ export class Timeline {
159
176
  }
160
177
 
161
178
  dimmed(n) {
179
+ if (this.modelFilter && modelFamily(n.model) !== this.modelFilter) return true;
162
180
  if (!this.filter) return false;
163
181
  if (this.keepWaiting && this.waiting.has(n.id)) return false;
164
182
  return !this.filter.has(n.status);
@@ -210,10 +228,12 @@ export class Timeline {
210
228
  // The rows are drawn again from the data each time; a keyboard reader's place among them is kept.
211
229
  const focused = this.rowsEl.contains?.(document.activeElement) ? document.activeElement.dataset?.key ?? null : null;
212
230
  const top = this.body.scrollTop;
231
+ const ids = groupsOf(this.nodes, this.group).map((g) => g.id);
213
232
  const list = rows(this.nodes, {
214
- group: this.group, folded: this.folded, expanded: this.expanded, waiting: this.waiting,
233
+ group: this.group, folded: foldedSet(ids, this.fold, this.all), expanded: this.all === true ? new Set(ids) : this.expanded, waiting: this.waiting,
215
234
  approvals: this.owned?.approvals ?? null, now,
216
235
  });
236
+ this.lastRows = list;
217
237
  this.rowsEl.replaceChildren(...list.map((r) => this.#row(r, win, width)));
218
238
  this.body.scrollTop = top;
219
239
  if (focused) [...this.rowsEl.querySelectorAll('[data-key]')].find((el) => el.dataset.key === focused)?.focus();
@@ -303,8 +323,9 @@ export class Timeline {
303
323
  chev.innerHTML = '<svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M6 4l4 4-4 4"/></svg>';
304
324
  lab.append(chev, mk('span', 'tl-name', r.label), mk('span', 'row-fill'), mk('span', 'sub', r.sub));
305
325
  lab.addEventListener('click', () => {
306
- if (this.folded.has(r.id)) this.folded.delete(r.id); else this.folded.add(r.id);
326
+ this.fold.set(r.id, !r.folded);
307
327
  this.render();
328
+ this.#changed();
308
329
  });
309
330
  this.#bars(track, r.lanes, win, width);
310
331
  } else if (r.kind === 'merged') {
@@ -320,8 +341,13 @@ export class Timeline {
320
341
  if (tile) lab.append(tile);
321
342
  const sub = mk('span', 'sub', r.sub);
322
343
  if (r.tone) sub.dataset.tone = r.tone;
323
- lab.append(mk('span', 'tl-name', r.label), mk('span', 'row-fill'), sub);
324
- lab.title = hoverOf(r.node);
344
+ // The model the row's agent ran on, before its state.
345
+ const mdl = modelOf(r.node);
346
+ const badge = mk('span', 'tl-model', `${mdl.ran}${mdl.differs ? ' \u2260' : ''}`);
347
+ badge.hidden = !mdl.ran;
348
+ badge.title = mdl.title;
349
+ lab.append(mk('span', 'tl-name', r.label), mk('span', 'row-fill'), badge, sub);
350
+ lab.title = [hoverOf(r.node), mdl.ran ? mdl.title : null].filter(Boolean).join('\n');
325
351
  // One listener, on the row: the label is a button inside it, and its click arrives here too. Two
326
352
  // listeners chose the agent and un-chose it in the same click.
327
353
  row.addEventListener('click', () => this.#choose(r.id));
@@ -5,6 +5,8 @@
5
5
  // "~", and a note that says what it is. A session with tokens from a model the price table does not have gets
6
6
  // a dash, not a smaller number.
7
7
 
8
+ import { modelName } from './names.js';
9
+
8
10
  export const COST_NOTE = 'Estimated at API list price; on a subscription it is not what you are billed';
9
11
  export const NO_COST = '—';
10
12
 
@@ -54,24 +56,63 @@ export function costNote(u) {
54
56
  return `No estimate: the price table has no row for ${u.unpriced.join(', ')}`;
55
57
  }
56
58
 
57
- /** The navigator's one line: time · tokens · ~cost. Null when nothing was read. */
58
- export function usageLine(u) {
59
+ /** "40s", "4m 40s", "3h 5m": a span that is being counted, so the seconds show while they matter. */
60
+ export function fmtRunning(ms) {
61
+ if (ms == null || !Number.isFinite(ms) || ms < 0) return null;
62
+ const s = Math.floor(ms / 1000);
63
+ if (s < 60) return `${s}s`;
64
+ if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`;
65
+ return fmtSpan(ms);
66
+ }
67
+
68
+ /**
69
+ * The navigator's one line: time · new tokens · ~cost. Null when nothing was read.
70
+ * @param live the session is running: its time is counted from its first record to now
71
+ */
72
+ export function usageLine(u, { live = false, now = null } = {}) {
59
73
  if (!u) return null;
60
74
  const t = timeOf(u);
61
- const tokens = typeof u.tokens === 'number' ? u.tokens : u.tokens?.total;
62
- return [t ? fmtSpan(t.ms) : null, tokens ? fmtCount(tokens) : null, tokens ? fmtCost(u.cost) : null].filter(Boolean).join(' · ') || null;
75
+ const span = live && u.startedAt != null && now != null ? fmtSpan(now - u.startedAt) : (t ? fmtSpan(t.ms) : null);
76
+ const tokens = typeof u.tokens === 'number' ? u.tokens : u.tokens?.fresh;
77
+ return [span, tokens ? fmtCount(tokens) : null, tokens ? fmtCost(u.cost) : null].filter(Boolean).join(' · ') || null;
63
78
  }
64
79
 
65
- /** The summary box's rows: [label, value, note]. */
66
- export function summaryRows(u) {
80
+ const kinds = (k, cached = true) => [
81
+ ['Input', fmtCount(k?.input ?? 0)], ['Output', fmtCount(k?.output ?? 0)], ['Cache write', fmtCount(k?.cacheWrite ?? 0)],
82
+ ...(cached ? [['Cache read', fmtCount(k?.cacheRead ?? 0)]] : []),
83
+ ];
84
+
85
+ /**
86
+ * The summary box's rows: { label, value, note, detail }. `detail` is what a click on the row opens: the same
87
+ * number broken down, as [name, value] pairs.
88
+ *
89
+ * "Tokens" is what was new: input, output and cache writes. What was read back from the cache is its own row —
90
+ * it is the same context counted again on every response, and added in it made a session that had just opened
91
+ * read as millions.
92
+ *
93
+ * @param opts.live the session is running: its time is counted from its first record to `opts.now`
94
+ */
95
+ export function summaryRows(u, { live = false, now = null } = {}) {
67
96
  if (!u) return [];
68
- const t = timeOf(u);
69
97
  const k = u.tokens ?? {};
70
98
  const rows = [];
71
- if (t) rows.push([t.kind === 'worked' ? 'Working time' : 'First to last record', fmtSpan(t.ms), t.note]);
72
- if (t?.kind === 'worked' && u.durationMs != null) rows.push(['First to last record', fmtSpan(u.durationMs), 'Pauses included']);
73
- rows.push(['Tokens', fmtCount(k.total ?? 0),
74
- `Agents included, each response counted once\ninput ${fmtCount(k.input ?? 0)} · output ${fmtCount(k.output ?? 0)} · cache write ${fmtCount(k.cacheWrite ?? 0)} · cache read ${fmtCount(k.cacheRead ?? 0)}`]);
75
- rows.push(['Cost', k.total ? fmtCost(u.cost) : NO_COST, costNote(u)]);
99
+ const t = timeOf(u);
100
+ if (live && u.startedAt != null && now != null) {
101
+ rows.push({ label: 'Running for', value: fmtRunning(now - u.startedAt), note: 'From the session\'s first record to now' });
102
+ } else if (t) {
103
+ rows.push({ label: t.kind === 'worked' ? 'Working time' : 'First to last record', value: fmtSpan(t.ms), note: t.note });
104
+ if (t.kind === 'worked' && u.durationMs != null) rows.push({ label: 'First to last record', value: fmtSpan(u.durationMs), note: 'Pauses included; the agents\' transcripts too' });
105
+ }
106
+ rows.push({ label: 'Tokens', value: fmtCount(k.fresh ?? 0), note: 'New tokens: input, output and cache writes. Agents included, each response counted once', detail: kinds(k, false) });
107
+ rows.push({ label: 'From cache', value: fmtCount(k.cacheRead ?? 0), note: 'Read back from the prompt cache: the same context, counted on every response that reused it' });
108
+ const part = (label, p, note) => {
109
+ if (!p?.tokens?.total) return;
110
+ rows.push({ label, value: `${fmtCount(p.tokens.fresh)} · ${fmtCost(p.cost)}`, note: `${note}: new tokens and estimated cost\n${costNote(p)}`, detail: kinds(p.tokens) });
111
+ };
112
+ part('Session', u.parts?.session, 'The session\'s own transcript');
113
+ part('Agents', u.parts?.agents, 'Everything its agents did');
114
+ // Behind the cost: each model's new tokens and what they come to. A model with no price says so with a dash.
115
+ const perModel = Object.entries(u.byModel ?? {}).map(([model, t]) => [modelName(model), `${fmtCount(t.tokens.fresh)} \u00b7 ${fmtCost(t.cost)}`]);
116
+ rows.push({ label: 'Cost', value: k.total ? fmtCost(u.cost) : NO_COST, note: costNote(u), detail: perModel.length ? perModel : undefined });
76
117
  return rows;
77
118
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crewforth",
3
- "version": "3.1.0-rc.4",
3
+ "version": "3.1.0-rc.6",
4
4
  "description": "Crewforth — your engineering crew for Claude Code: agents, skills and tool-level gates, in any stack.",
5
5
  "bin": {
6
6
  "crewforth": "bin/cli.js"