stagent 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. package/README.md +44 -31
  2. package/dist/cli.js +24 -0
  3. package/docs/.coverage-gaps.json +154 -24
  4. package/docs/.last-generated +1 -1
  5. package/docs/features/agent-intelligence.md +12 -2
  6. package/docs/features/chat.md +40 -5
  7. package/docs/features/cost-usage.md +1 -1
  8. package/docs/features/documents.md +5 -2
  9. package/docs/features/inbox-notifications.md +10 -2
  10. package/docs/features/keyboard-navigation.md +12 -3
  11. package/docs/features/provider-runtimes.md +16 -2
  12. package/docs/features/settings.md +2 -2
  13. package/docs/features/shared-components.md +7 -3
  14. package/docs/features/tables.md +3 -1
  15. package/docs/features/tool-permissions.md +6 -2
  16. package/docs/features/workflows.md +6 -2
  17. package/docs/getting-started.md +1 -1
  18. package/docs/index.md +1 -1
  19. package/docs/journeys/developer.md +25 -2
  20. package/docs/journeys/personal-use.md +12 -5
  21. package/docs/journeys/power-user.md +45 -14
  22. package/docs/journeys/work-use.md +17 -8
  23. package/docs/manifest.json +15 -15
  24. package/docs/superpowers/plans/2026-04-07-instance-bootstrap.md +2 -2
  25. package/docs/superpowers/plans/2026-04-14-chat-command-namespace-refactor.md +1390 -0
  26. package/docs/superpowers/plans/2026-04-14-chat-environment-integration.md +1561 -0
  27. package/docs/superpowers/plans/2026-04-14-chat-polish-bundle-v1.md +1219 -0
  28. package/docs/superpowers/plans/2026-04-14-chat-session-persistence-provider-closeout.md +399 -0
  29. package/next.config.mjs +1 -0
  30. package/package.json +3 -3
  31. package/src/app/api/chat/conversations/[id]/skills/__tests__/activate.test.ts +141 -0
  32. package/src/app/api/chat/conversations/[id]/skills/activate/route.ts +74 -0
  33. package/src/app/api/chat/conversations/[id]/skills/deactivate/route.ts +33 -0
  34. package/src/app/api/chat/export/route.ts +52 -0
  35. package/src/app/api/chat/files/search/route.ts +50 -0
  36. package/src/app/api/environment/rescan-if-stale/__tests__/route.test.ts +45 -0
  37. package/src/app/api/environment/rescan-if-stale/route.ts +23 -0
  38. package/src/app/api/environment/skills/route.ts +13 -0
  39. package/src/app/api/schedules/[id]/execute/route.ts +2 -2
  40. package/src/app/api/settings/chat/pins/route.ts +94 -0
  41. package/src/app/api/settings/chat/saved-searches/__tests__/route.test.ts +119 -0
  42. package/src/app/api/settings/chat/saved-searches/route.ts +79 -0
  43. package/src/app/api/settings/environment/route.ts +26 -0
  44. package/src/app/api/tasks/[id]/execute/route.ts +52 -12
  45. package/src/app/api/tasks/[id]/respond/route.ts +31 -15
  46. package/src/app/api/tasks/[id]/resume/route.ts +24 -3
  47. package/src/app/documents/page.tsx +4 -1
  48. package/src/app/settings/page.tsx +2 -0
  49. package/src/components/book/content-blocks.tsx +1 -1
  50. package/src/components/chat/__tests__/capability-banner.test.tsx +38 -0
  51. package/src/components/chat/__tests__/chat-session-provider.test.tsx +166 -1
  52. package/src/components/chat/__tests__/skill-row.test.tsx +91 -0
  53. package/src/components/chat/capability-banner.tsx +68 -0
  54. package/src/components/chat/chat-command-popover.tsx +668 -47
  55. package/src/components/chat/chat-input.tsx +103 -8
  56. package/src/components/chat/chat-message.tsx +12 -3
  57. package/src/components/chat/chat-session-provider.tsx +73 -3
  58. package/src/components/chat/chat-shell.tsx +62 -3
  59. package/src/components/chat/command-tab-bar.tsx +68 -0
  60. package/src/components/chat/conversation-template-picker.tsx +421 -0
  61. package/src/components/chat/help-dialog.tsx +39 -0
  62. package/src/components/chat/skill-composition-conflict-dialog.tsx +96 -0
  63. package/src/components/chat/skill-row.tsx +147 -0
  64. package/src/components/documents/document-browser.tsx +37 -19
  65. package/src/components/notifications/__tests__/permission-response-actions.test.tsx +70 -0
  66. package/src/components/notifications/permission-response-actions.tsx +155 -1
  67. package/src/components/playbook/playbook-detail-view.tsx +1 -1
  68. package/src/components/settings/environment-section.tsx +102 -0
  69. package/src/components/shared/__tests__/filter-hint.test.tsx +40 -0
  70. package/src/components/shared/__tests__/saved-searches-manager.test.tsx +147 -0
  71. package/src/components/shared/command-palette.tsx +262 -2
  72. package/src/components/shared/filter-hint.tsx +70 -0
  73. package/src/components/shared/filter-input.tsx +59 -0
  74. package/src/components/shared/saved-searches-manager.tsx +199 -0
  75. package/src/components/tasks/task-bento-grid.tsx +12 -2
  76. package/src/components/tasks/task-card.tsx +3 -0
  77. package/src/components/tasks/task-chip-bar.tsx +30 -1
  78. package/src/hooks/__tests__/use-chat-autocomplete-tabs.test.ts +47 -0
  79. package/src/hooks/__tests__/use-saved-searches.test.ts +70 -0
  80. package/src/hooks/use-active-skills.ts +110 -0
  81. package/src/hooks/use-chat-autocomplete.ts +120 -7
  82. package/src/hooks/use-enriched-skills.ts +19 -0
  83. package/src/hooks/use-pinned-entries.ts +104 -0
  84. package/src/hooks/use-recent-user-messages.ts +19 -0
  85. package/src/hooks/use-saved-searches.ts +142 -0
  86. package/src/lib/agents/__tests__/claude-agent-sdk-options.test.ts +56 -0
  87. package/src/lib/agents/__tests__/claude-agent.test.ts +17 -4
  88. package/src/lib/agents/__tests__/task-dispatch.test.ts +166 -0
  89. package/src/lib/agents/__tests__/tool-permissions.test.ts +60 -0
  90. package/src/lib/agents/claude-agent.ts +105 -46
  91. package/src/lib/agents/handoff/bus.ts +2 -2
  92. package/src/lib/agents/profiles/__tests__/list-fused-profiles.test.ts +110 -0
  93. package/src/lib/agents/profiles/__tests__/registry.test.ts +47 -0
  94. package/src/lib/agents/profiles/builtins/upgrade-assistant/SKILL.md +30 -3
  95. package/src/lib/agents/profiles/builtins/upgrade-assistant/profile.yaml +6 -2
  96. package/src/lib/agents/profiles/list-fused-profiles.ts +104 -0
  97. package/src/lib/agents/profiles/registry.ts +97 -22
  98. package/src/lib/agents/profiles/types.ts +7 -1
  99. package/src/lib/agents/router.ts +3 -6
  100. package/src/lib/agents/runtime/__tests__/catalog.test.ts +130 -0
  101. package/src/lib/agents/runtime/__tests__/execution-target.test.ts +183 -0
  102. package/src/lib/agents/runtime/anthropic-direct.ts +8 -0
  103. package/src/lib/agents/runtime/catalog.ts +121 -0
  104. package/src/lib/agents/runtime/claude-sdk.ts +32 -0
  105. package/src/lib/agents/runtime/execution-target.ts +456 -0
  106. package/src/lib/agents/runtime/index.ts +4 -0
  107. package/src/lib/agents/runtime/launch-failure.ts +101 -0
  108. package/src/lib/agents/runtime/openai-codex.ts +35 -0
  109. package/src/lib/agents/runtime/openai-direct.ts +8 -0
  110. package/src/lib/agents/task-dispatch.ts +220 -0
  111. package/src/lib/agents/tool-permissions.ts +16 -1
  112. package/src/lib/chat/__tests__/active-skill-injection.test.ts +261 -0
  113. package/src/lib/chat/__tests__/clean-filter-input.test.ts +68 -0
  114. package/src/lib/chat/__tests__/command-tabs.test.ts +68 -0
  115. package/src/lib/chat/__tests__/context-builder-files.test.ts +112 -0
  116. package/src/lib/chat/__tests__/dismissals.test.ts +65 -0
  117. package/src/lib/chat/__tests__/engine-sdk-options.test.ts +117 -0
  118. package/src/lib/chat/__tests__/skill-conflict.test.ts +35 -0
  119. package/src/lib/chat/__tests__/types.test.ts +28 -0
  120. package/src/lib/chat/active-skills.ts +31 -0
  121. package/src/lib/chat/clean-filter-input.ts +30 -0
  122. package/src/lib/chat/codex-engine.ts +30 -7
  123. package/src/lib/chat/command-tabs.ts +61 -0
  124. package/src/lib/chat/context-builder.ts +141 -1
  125. package/src/lib/chat/dismissals.ts +73 -0
  126. package/src/lib/chat/engine.ts +109 -15
  127. package/src/lib/chat/files/__tests__/search.test.ts +135 -0
  128. package/src/lib/chat/files/expand-mention.ts +76 -0
  129. package/src/lib/chat/files/search.ts +99 -0
  130. package/src/lib/chat/skill-composition.ts +210 -0
  131. package/src/lib/chat/skill-conflict.ts +105 -0
  132. package/src/lib/chat/stagent-tools.ts +6 -19
  133. package/src/lib/chat/stream-telemetry.ts +9 -4
  134. package/src/lib/chat/system-prompt.ts +22 -0
  135. package/src/lib/chat/tool-catalog.ts +33 -3
  136. package/src/lib/chat/tools/__tests__/profile-tools.test.ts +51 -0
  137. package/src/lib/chat/tools/__tests__/settings-tools.test.ts +294 -0
  138. package/src/lib/chat/tools/__tests__/skill-tools.test.ts +474 -0
  139. package/src/lib/chat/tools/__tests__/task-tools.test.ts +47 -0
  140. package/src/lib/chat/tools/__tests__/workflow-tools-dedup.test.ts +134 -0
  141. package/src/lib/chat/tools/blueprint-tools.ts +190 -0
  142. package/src/lib/chat/tools/helpers.ts +2 -0
  143. package/src/lib/chat/tools/profile-tools.ts +120 -23
  144. package/src/lib/chat/tools/skill-tools.ts +183 -0
  145. package/src/lib/chat/tools/task-tools.ts +6 -2
  146. package/src/lib/chat/tools/workflow-tools.ts +61 -20
  147. package/src/lib/chat/types.ts +15 -0
  148. package/src/lib/constants/settings.ts +2 -0
  149. package/src/lib/data/clear.ts +2 -6
  150. package/src/lib/db/bootstrap.ts +17 -0
  151. package/src/lib/db/schema.ts +26 -0
  152. package/src/lib/environment/__tests__/auto-promote.test.ts +132 -0
  153. package/src/lib/environment/__tests__/list-skills-enriched.test.ts +55 -0
  154. package/src/lib/environment/__tests__/skill-enrichment.test.ts +129 -0
  155. package/src/lib/environment/__tests__/skill-recommendations.test.ts +87 -0
  156. package/src/lib/environment/data.ts +9 -0
  157. package/src/lib/environment/list-skills.ts +176 -0
  158. package/src/lib/environment/parsers/__tests__/skill.test.ts +54 -0
  159. package/src/lib/environment/parsers/skill.ts +26 -5
  160. package/src/lib/environment/profile-generator.ts +56 -2
  161. package/src/lib/environment/skill-enrichment.ts +106 -0
  162. package/src/lib/environment/skill-recommendations.ts +66 -0
  163. package/src/lib/filters/__tests__/parse.quoted.test.ts +40 -0
  164. package/src/lib/filters/__tests__/parse.test.ts +135 -0
  165. package/src/lib/filters/parse.ts +86 -0
  166. package/src/lib/instance/__tests__/detect.test.ts +1 -1
  167. package/src/lib/instance/__tests__/upgrade-poller.test.ts +50 -0
  168. package/src/lib/instance/fingerprint.ts +8 -10
  169. package/src/lib/instance/upgrade-poller.ts +53 -1
  170. package/src/lib/schedules/scheduler.ts +4 -4
  171. package/src/lib/utils/stagent-paths.ts +4 -0
  172. package/src/lib/workflows/blueprints/__tests__/render-prompt.test.ts +124 -0
  173. package/src/lib/workflows/blueprints/render-prompt.ts +71 -0
  174. package/src/lib/workflows/blueprints/types.ts +6 -0
  175. package/src/lib/workflows/engine.ts +5 -3
  176. package/src/test/setup.ts +10 -0
@@ -0,0 +1,135 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { parseFilterInput, matchesClauses } from "../parse";
3
+
4
+ describe("parseFilterInput", () => {
5
+ it("returns empty result for empty input", () => {
6
+ expect(parseFilterInput("")).toEqual({ clauses: [], rawQuery: "" });
7
+ });
8
+
9
+ it("returns input as rawQuery when no clauses present", () => {
10
+ const out = parseFilterInput("hello world");
11
+ expect(out.clauses).toEqual([]);
12
+ expect(out.rawQuery).toBe("hello world");
13
+ });
14
+
15
+ it("parses a single clause and strips it from rawQuery", () => {
16
+ const out = parseFilterInput("#status:blocked");
17
+ expect(out.clauses).toEqual([{ key: "status", value: "blocked" }]);
18
+ expect(out.rawQuery).toBe("");
19
+ });
20
+
21
+ it("parses multiple clauses with AND semantics", () => {
22
+ const out = parseFilterInput("#status:blocked #priority:high");
23
+ expect(out.clauses).toEqual([
24
+ { key: "status", value: "blocked" },
25
+ { key: "priority", value: "high" },
26
+ ]);
27
+ expect(out.rawQuery).toBe("");
28
+ });
29
+
30
+ it("preserves raw text between and around clauses", () => {
31
+ const out = parseFilterInput("auth #status:blocked service #priority:high");
32
+ expect(out.clauses).toEqual([
33
+ { key: "status", value: "blocked" },
34
+ { key: "priority", value: "high" },
35
+ ]);
36
+ expect(out.rawQuery).toBe("auth service");
37
+ });
38
+
39
+ it("treats `#123` as raw-query text, not a clause (no colon)", () => {
40
+ const out = parseFilterInput("see #123 for context");
41
+ expect(out.clauses).toEqual([]);
42
+ expect(out.rawQuery).toBe("see #123 for context");
43
+ });
44
+
45
+ it("treats `#1abc:val` as raw-query text (key must start with a letter)", () => {
46
+ const out = parseFilterInput("#1abc:val");
47
+ expect(out.clauses).toEqual([]);
48
+ expect(out.rawQuery).toBe("#1abc:val");
49
+ });
50
+
51
+ it("accepts hyphens and underscores in keys", () => {
52
+ const out = parseFilterInput("#created-by:me #user_id:42");
53
+ expect(out.clauses).toEqual([
54
+ { key: "created-by", value: "me" },
55
+ { key: "user_id", value: "42" },
56
+ ]);
57
+ });
58
+
59
+ it("preserves case of values verbatim (keys keep case too)", () => {
60
+ const out = parseFilterInput("#Status:Blocked");
61
+ expect(out.clauses).toEqual([{ key: "Status", value: "Blocked" }]);
62
+ });
63
+
64
+ it("accepts back-to-back clauses without space between them", () => {
65
+ const out = parseFilterInput("#a:1#b:2");
66
+ expect(out.clauses).toEqual([
67
+ { key: "a", value: "1" },
68
+ { key: "b", value: "2" },
69
+ ]);
70
+ expect(out.rawQuery).toBe("");
71
+ });
72
+
73
+ it("collapses extra whitespace in rawQuery", () => {
74
+ const out = parseFilterInput(" foo bar ");
75
+ expect(out.rawQuery).toBe("foo bar");
76
+ });
77
+
78
+ it("handles values with special chars except whitespace", () => {
79
+ const out = parseFilterInput("#path:src/lib/filters.ts");
80
+ expect(out.clauses).toEqual([{ key: "path", value: "src/lib/filters.ts" }]);
81
+ });
82
+ });
83
+
84
+ describe("matchesClauses", () => {
85
+ const task = { id: "t1", status: "blocked", priority: "high", type: "task" };
86
+
87
+ it("returns true when clauses list is empty", () => {
88
+ expect(matchesClauses(task, [], {})).toBe(true);
89
+ });
90
+
91
+ it("returns true when all clauses match via predicates", () => {
92
+ const out = matchesClauses(
93
+ task,
94
+ [{ key: "status", value: "blocked" }],
95
+ { status: (t, v) => t.status === v }
96
+ );
97
+ expect(out).toBe(true);
98
+ });
99
+
100
+ it("returns false when any clause fails", () => {
101
+ const out = matchesClauses(
102
+ task,
103
+ [
104
+ { key: "status", value: "blocked" },
105
+ { key: "priority", value: "low" },
106
+ ],
107
+ {
108
+ status: (t, v) => t.status === v,
109
+ priority: (t, v) => t.priority === v,
110
+ }
111
+ );
112
+ expect(out).toBe(false);
113
+ });
114
+
115
+ it("silently skips unknown keys (does not fail the match)", () => {
116
+ const out = matchesClauses(
117
+ task,
118
+ [
119
+ { key: "status", value: "blocked" },
120
+ { key: "totally-unknown", value: "xyz" },
121
+ ],
122
+ { status: (t, v) => t.status === v }
123
+ );
124
+ expect(out).toBe(true);
125
+ });
126
+
127
+ it("normalizes key lookup to lowercase", () => {
128
+ const out = matchesClauses(
129
+ task,
130
+ [{ key: "Status", value: "blocked" }],
131
+ { status: (t, v) => t.status === v }
132
+ );
133
+ expect(out).toBe(true);
134
+ });
135
+ });
@@ -0,0 +1,86 @@
1
+ /**
2
+ * `#key:value` filter namespace parser.
3
+ *
4
+ * Pure function that extracts filter clauses from free-text input and returns
5
+ * the non-filter remainder as `rawQuery`. Designed to be reused across chat
6
+ * popovers (entity filtering) and list pages (URL state, FilterBar input).
7
+ *
8
+ * Syntax (v2):
9
+ * - `#key:value` — single clause. Keys are `[A-Za-z][\w-]*`, values are
10
+ * double-quoted strings `"..."` (may contain spaces or `#`) OR a whitespace/`#`-terminated bare run.
11
+ * - Multiple clauses may chain: `#status:blocked #priority:high` → two clauses.
12
+ * - Clauses may appear anywhere in the input; everything else becomes rawQuery.
13
+ * - Unknown keys pass through unchanged — the consumer decides what to do.
14
+ * - Tokens like `#123` (no colon) are treated as raw-query text, not clauses.
15
+ *
16
+ * Design notes:
17
+ * - AND-only. NOT/OR deferred to v2.
18
+ * - Case of keys is preserved; consumer normalizes if needed. Values are
19
+ * preserved verbatim (including case) — status codes are commonly lowercase.
20
+ * - rawQuery whitespace is collapsed to single spaces and trimmed so callers
21
+ * can feed it directly to a search input without extra cleanup.
22
+ */
23
+
24
+ export interface FilterClause {
25
+ key: string;
26
+ value: string;
27
+ }
28
+
29
+ export interface ParsedFilterInput {
30
+ clauses: FilterClause[];
31
+ rawQuery: string;
32
+ }
33
+
34
+ // Clause pattern: `#<key>:<value>`. Key must start with a letter to avoid
35
+ // eating `#123` hash references. Value may be either:
36
+ // - a double-quoted run of any non-quote chars: `"..."` (captured in group 2)
37
+ // - OR an unquoted whitespace/`#`-terminated run (captured in group 3)
38
+ // Exactly one of group 2 / group 3 will be defined per match.
39
+ const CLAUSE_PATTERN = /#([A-Za-z][\w-]*):(?:"([^"]*)"|([^\s#]+))/g;
40
+
41
+ export function parseFilterInput(input: string): ParsedFilterInput {
42
+ if (!input) return { clauses: [], rawQuery: "" };
43
+
44
+ const clauses: FilterClause[] = [];
45
+ let rawQuery = input;
46
+
47
+ // Replace each match with a single space to preserve word boundaries, then
48
+ // collapse whitespace. This is simpler than maintaining offsets and survives
49
+ // back-to-back clauses like `#a:1#b:2` (which we don't officially support
50
+ // but shouldn't crash on — the regex with `g` flag matches both).
51
+ rawQuery = rawQuery.replace(
52
+ CLAUSE_PATTERN,
53
+ (_match, key: string, quoted: string | undefined, bare: string | undefined) => {
54
+ const value = quoted !== undefined ? quoted : bare ?? "";
55
+ clauses.push({ key, value });
56
+ return " ";
57
+ }
58
+ );
59
+
60
+ rawQuery = rawQuery.replace(/\s+/g, " ").trim();
61
+
62
+ return { clauses, rawQuery };
63
+ }
64
+
65
+ /**
66
+ * Evaluate an object against a list of clauses using a caller-supplied
67
+ * predicate per key. Returns true if ALL clauses match (AND semantics) or
68
+ * the clauses list is empty.
69
+ *
70
+ * `predicates` maps known filter keys to value-checkers. Unknown keys are
71
+ * silently skipped (not considered a mismatch) so callers can layer their
72
+ * own matching logic without breaking on typos.
73
+ */
74
+ export function matchesClauses<T>(
75
+ item: T,
76
+ clauses: FilterClause[],
77
+ predicates: Record<string, (item: T, value: string) => boolean>
78
+ ): boolean {
79
+ if (clauses.length === 0) return true;
80
+ for (const clause of clauses) {
81
+ const predicate = predicates[clause.key.toLowerCase()];
82
+ if (!predicate) continue; // unknown key → skip, per spec
83
+ if (!predicate(item, clause.value)) return false;
84
+ }
85
+ return true;
86
+ }
@@ -83,7 +83,7 @@ describe("isPrivateInstance", () => {
83
83
  });
84
84
 
85
85
  it("returns true when STAGENT_DATA_DIR is a custom path", async () => {
86
- vi.stubEnv("STAGENT_DATA_DIR", "/Users/navam/.stagent-wealth");
86
+ vi.stubEnv("STAGENT_DATA_DIR", "/Users/manavsehgal/.stagent-wealth");
87
87
  const { isPrivateInstance } = await loadDetect();
88
88
  expect(isPrivateInstance()).toBe(true);
89
89
  });
@@ -76,6 +76,56 @@ describe("tick", () => {
76
76
  expect(getUpgradeState().pollFailureCount).toBe(3);
77
77
  });
78
78
 
79
+ it("inserts one failure notification after 3 consecutive failures, dedupes on the 4th, clears on success", async () => {
80
+ const { tick } = await import("../upgrade-poller");
81
+ const { db } = await import("@/lib/db");
82
+ const { notifications } = await import("@/lib/db/schema");
83
+ const { eq, and, isNull } = await import("drizzle-orm");
84
+
85
+ // 2 failures: no notification yet
86
+ await tick(tempDir);
87
+ await tick(tempDir);
88
+ let open = await db
89
+ .select()
90
+ .from(notifications)
91
+ .where(and(eq(notifications.toolName, "upgrade_check_failing"), isNull(notifications.respondedAt)));
92
+ expect(open).toHaveLength(0);
93
+
94
+ // 3rd failure: notification created
95
+ await tick(tempDir);
96
+ open = await db
97
+ .select()
98
+ .from(notifications)
99
+ .where(and(eq(notifications.toolName, "upgrade_check_failing"), isNull(notifications.respondedAt)));
100
+ expect(open).toHaveLength(1);
101
+ expect(open[0].title).toBe("Upgrade check failing");
102
+ expect(open[0].body).toContain("Last 3 upgrade checks failed");
103
+
104
+ // 4th failure: still exactly one open notification (deduped)
105
+ await tick(tempDir);
106
+ open = await db
107
+ .select()
108
+ .from(notifications)
109
+ .where(and(eq(notifications.toolName, "upgrade_check_failing"), isNull(notifications.respondedAt)));
110
+ expect(open).toHaveLength(1);
111
+
112
+ // Success clears the notification
113
+ const bareDir = mkdtempSync(join(tmpdir(), "stagent-bare-"));
114
+ try {
115
+ runGit(["init", "--bare", "-b", "main"], bareDir);
116
+ runGit(["remote", "add", "origin", bareDir], tempDir);
117
+ runGit(["push", "origin", "main"], tempDir);
118
+ await tick(tempDir);
119
+ open = await db
120
+ .select()
121
+ .from(notifications)
122
+ .where(and(eq(notifications.toolName, "upgrade_check_failing"), isNull(notifications.respondedAt)));
123
+ expect(open).toHaveLength(0);
124
+ } finally {
125
+ rmSync(bareDir, { recursive: true, force: true });
126
+ }
127
+ });
128
+
79
129
  it("successfully updates state with zero commitsBehind when local == origin/main", async () => {
80
130
  // Set up a local 'origin' remote pointing to a bare copy of the same repo
81
131
  const bareDir = mkdtempSync(join(tmpdir(), "stagent-bare-"));
@@ -2,24 +2,22 @@
2
2
  * Machine fingerprint generator.
3
3
  *
4
4
  * Produces a stable, non-identifying hash that uniquely identifies the machine
5
- * this stagent instance is running on. Used by the cloud license validator to
6
- * meter seats per machine per the hybrid licensing model (TDR-030): local
7
- * features are unlimited, cloud features count seats using
8
- * (email, machineFingerprint, instanceId) tuples.
5
+ * this stagent instance is running on. Used to give each stagent instance a
6
+ * durable identity (e.g., for telemetry correlation and multi-instance
7
+ * disambiguation); no billing or cloud-metering dependency.
9
8
  *
10
9
  * The fingerprint is derived from:
11
10
  * 1. os.hostname() — e.g., "macbook-pro.local"
12
- * 2. os.userInfo().username — e.g., "navam"
11
+ * 2. os.userInfo().username — e.g., "manavsehgal"
13
12
  * 3. SHA-256 of the first non-internal MAC address
14
13
  *
15
- * The MAC is hashed before it leaves the process so the network identifier
16
- * never appears in logs, telemetry, or cloud payloads. The combined inputs
17
- * are SHA-256'd together to produce a 64-character hex string.
14
+ * The MAC is hashed before it leaves the process so the raw network identifier
15
+ * never appears in logs or telemetry. The combined inputs are SHA-256'd
16
+ * together to produce a 64-character hex string.
18
17
  *
19
18
  * Stability: the fingerprint is stable across reboots and stagent restarts
20
19
  * on the same machine. It changes if the user renames their account, renames
21
- * their machine, or swaps network hardware. Machine fingerprint migration
22
- * policy (e.g., 7-day grace) is handled server-side by the edge function.
20
+ * their machine, or swaps network hardware.
23
21
  */
24
22
 
25
23
  import { createHash } from "crypto";
@@ -10,6 +10,9 @@
10
10
 
11
11
  import { existsSync, mkdirSync, writeFileSync, unlinkSync, statSync } from "fs";
12
12
  import { join } from "path";
13
+ import { and, eq, isNull } from "drizzle-orm";
14
+ import { db } from "@/lib/db";
15
+ import { notifications } from "@/lib/db/schema";
13
16
  import { isDevMode, hasGitDir } from "./detect";
14
17
  import { createGitOps } from "./git-ops";
15
18
  import { getUpgradeState, setUpgradeState } from "./settings";
@@ -17,6 +20,9 @@ import { getUpgradeState, setUpgradeState } from "./settings";
17
20
  const POLL_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
18
21
  const LOCK_TTL_MS = 5 * 60 * 1000; // 5 minutes
19
22
  const LOCK_FILENAME = ".stagent-upgrade-check.lock";
23
+ const FAILURE_THRESHOLD = 3;
24
+ // Sentinel value in notifications.toolName so we can dedupe / clear the banner.
25
+ const FAILURE_MARKER = "upgrade_check_failing";
20
26
 
21
27
  let intervalHandle: ReturnType<typeof setInterval> | null = null;
22
28
  let ticking = false;
@@ -69,12 +75,16 @@ export async function tick(cwd: string = process.cwd()): Promise<UpgradeTickResu
69
75
  git.fetchOrigin();
70
76
  } catch (err) {
71
77
  const message = err instanceof Error ? err.message : String(err);
78
+ const nextFailureCount = current.pollFailureCount + 1;
72
79
  await setUpgradeState({
73
80
  ...current,
74
81
  lastPolledAt: Math.floor(Date.now() / 1000),
75
- pollFailureCount: current.pollFailureCount + 1,
82
+ pollFailureCount: nextFailureCount,
76
83
  lastPollError: message,
77
84
  });
85
+ if (nextFailureCount >= FAILURE_THRESHOLD) {
86
+ await ensureFailureNotification(nextFailureCount, message);
87
+ }
78
88
  return { skipped: "fetch_failed", error: message };
79
89
  }
80
90
 
@@ -94,6 +104,9 @@ export async function tick(cwd: string = process.cwd()): Promise<UpgradeTickResu
94
104
  lastPollError: null,
95
105
  };
96
106
  await setUpgradeState(newState);
107
+ if (current.pollFailureCount >= FAILURE_THRESHOLD) {
108
+ await clearFailureNotification();
109
+ }
97
110
  return { updated: newState };
98
111
  } finally {
99
112
  releaseLock(lockPath);
@@ -151,3 +164,42 @@ function releaseLock(lockPath: string): void {
151
164
  /* ignore */
152
165
  }
153
166
  }
167
+
168
+ /**
169
+ * Insert a persistent "Upgrade check failing" notification, at most one at a time.
170
+ * Dedup key: `notifications.toolName = FAILURE_MARKER` with no response set.
171
+ */
172
+ async function ensureFailureNotification(failureCount: number, error: string): Promise<void> {
173
+ try {
174
+ const existing = await db
175
+ .select({ id: notifications.id })
176
+ .from(notifications)
177
+ .where(and(eq(notifications.toolName, FAILURE_MARKER), isNull(notifications.respondedAt)))
178
+ .limit(1);
179
+ if (existing.length > 0) return;
180
+
181
+ await db.insert(notifications).values({
182
+ id: crypto.randomUUID(),
183
+ type: "agent_message",
184
+ title: "Upgrade check failing",
185
+ body: `Last ${failureCount} upgrade checks failed: ${error.slice(0, 400)}. Open Settings → Instance to retry.`,
186
+ toolName: FAILURE_MARKER,
187
+ read: false,
188
+ createdAt: new Date(),
189
+ });
190
+ } catch (err) {
191
+ console.error("[upgrade-poller] failed to insert failure notification:", err);
192
+ }
193
+ }
194
+
195
+ /** Mark any open failure notification as responded so the inbox clears it. */
196
+ async function clearFailureNotification(): Promise<void> {
197
+ try {
198
+ await db
199
+ .update(notifications)
200
+ .set({ respondedAt: new Date(), read: true })
201
+ .where(and(eq(notifications.toolName, FAILURE_MARKER), isNull(notifications.respondedAt)));
202
+ } catch (err) {
203
+ console.error("[upgrade-poller] failed to clear failure notification:", err);
204
+ }
205
+ }
@@ -16,7 +16,7 @@ import { schedules, tasks, agentLogs, scheduleDocumentInputs, documents, workflo
16
16
  import { eq, and, lte, inArray, sql, asc, isNotNull } from "drizzle-orm";
17
17
  import { resumeWorkflow } from "@/lib/workflows/engine";
18
18
  import { computeNextFireTime } from "./interval-parser";
19
- import { executeTaskWithRuntime } from "@/lib/agents/runtime";
19
+ import { startTaskExecution } from "@/lib/agents/task-dispatch";
20
20
  import { getSetting } from "@/lib/settings/helpers";
21
21
  import { SETTINGS_KEYS } from "@/lib/constants/settings";
22
22
  import { checkActiveHours } from "./active-hours";
@@ -92,7 +92,7 @@ export async function drainQueue(): Promise<void> {
92
92
 
93
93
  console.log(`[scheduler] draining queue → running task ${nextQueued.id}`);
94
94
  try {
95
- await executeTaskWithRuntime(nextQueued.id);
95
+ await startTaskExecution(nextQueued.id);
96
96
  } catch (err) {
97
97
  console.error(`[scheduler] drain task ${nextQueued.id} failed:`, err);
98
98
  }
@@ -600,7 +600,7 @@ async function fireSchedule(
600
600
  // poll loop must keep claiming other due schedules), but on completion we
601
601
  // record metrics and trigger drainQueue() so any tasks queued by colliding
602
602
  // schedules execute immediately instead of waiting for the next poll.
603
- executeTaskWithRuntime(taskId)
603
+ startTaskExecution(taskId)
604
604
  .catch((err) => {
605
605
  console.error(
606
606
  `[scheduler] task execution failed for schedule ${schedule.id}, task ${taskId}:`,
@@ -750,7 +750,7 @@ async function fireHeartbeat(
750
750
 
751
751
  // 5. Execute and wait for result (with timeout)
752
752
  try {
753
- await executeTaskWithRuntime(evalTaskId);
753
+ await startTaskExecution(evalTaskId);
754
754
  } catch (err) {
755
755
  console.error(`[scheduler] heartbeat evaluation failed for "${schedule.name}":`, err);
756
756
  }
@@ -52,3 +52,7 @@ export function getStagentCodexConfigPath(): string {
52
52
  export function getStagentCodexAuthPath(): string {
53
53
  return join(getStagentCodexDir(), "auth.json");
54
54
  }
55
+
56
+ export function getStagentProfilesDir(): string {
57
+ return join(getStagentDataDir(), "profiles");
58
+ }
@@ -0,0 +1,124 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ renderBlueprintPrompt,
4
+ UnresolvedTokenError,
5
+ } from "../render-prompt";
6
+ import type { WorkflowBlueprint } from "../types";
7
+
8
+ function makeBlueprint(
9
+ overrides: Partial<WorkflowBlueprint> = {}
10
+ ): WorkflowBlueprint {
11
+ return {
12
+ id: "test-bp",
13
+ name: "Test Blueprint",
14
+ description: "A blueprint for tests",
15
+ version: "1.0.0",
16
+ domain: "work",
17
+ tags: [],
18
+ pattern: "sequence",
19
+ variables: [],
20
+ steps: [],
21
+ ...overrides,
22
+ };
23
+ }
24
+
25
+ describe("renderBlueprintPrompt", () => {
26
+ it("uses chatPrompt when present", () => {
27
+ const bp = makeBlueprint({
28
+ chatPrompt: "Research {{topic}} for {{timeframe}}.",
29
+ steps: [
30
+ { name: "s1", promptTemplate: "fallback should not be used", requiresApproval: false },
31
+ ],
32
+ });
33
+ const out = renderBlueprintPrompt(bp, { topic: "AI agents", timeframe: "2026" });
34
+ expect(out.firstMessage).toBe("Research AI agents for 2026.");
35
+ });
36
+
37
+ it("falls back to steps[0].promptTemplate when chatPrompt absent", () => {
38
+ const bp = makeBlueprint({
39
+ steps: [
40
+ {
41
+ name: "s1",
42
+ promptTemplate: "Summarize {{topic}} in ≤500 words.",
43
+ requiresApproval: false,
44
+ },
45
+ ],
46
+ });
47
+ const out = renderBlueprintPrompt(bp, { topic: "browser agents" });
48
+ expect(out.firstMessage).toBe("Summarize browser agents in ≤500 words.");
49
+ });
50
+
51
+ it("renders title with variable substitution", () => {
52
+ const bp = makeBlueprint({
53
+ name: "Research {{topic}}",
54
+ chatPrompt: "go",
55
+ });
56
+ const out = renderBlueprintPrompt(bp, { topic: "SSE streams" });
57
+ expect(out.title).toBe("Research SSE streams");
58
+ });
59
+
60
+ it("returns empty firstMessage when no chatPrompt and no steps", () => {
61
+ const bp = makeBlueprint({ steps: [] });
62
+ const out = renderBlueprintPrompt(bp, {});
63
+ expect(out.firstMessage).toBe("");
64
+ expect(out.title).toBe("Test Blueprint");
65
+ });
66
+
67
+ it("substitutes missing optional variables with empty string (non-strict)", () => {
68
+ const bp = makeBlueprint({
69
+ chatPrompt: "Topic: {{topic}}. Scope: {{scope}}.",
70
+ });
71
+ const out = renderBlueprintPrompt(bp, { topic: "rate limits" });
72
+ expect(out.firstMessage).toBe("Topic: rate limits. Scope: .");
73
+ });
74
+
75
+ it("handles {{#if}} conditional blocks", () => {
76
+ const bp = makeBlueprint({
77
+ chatPrompt:
78
+ "Base prompt.{{#if extra}}\n\nExtra: {{extra}}{{/if}}",
79
+ });
80
+ const withExtra = renderBlueprintPrompt(bp, { extra: "deep dive" });
81
+ expect(withExtra.firstMessage).toBe("Base prompt.\n\nExtra: deep dive");
82
+
83
+ const withoutExtra = renderBlueprintPrompt(bp, {});
84
+ expect(withoutExtra.firstMessage).toBe("Base prompt.");
85
+ });
86
+
87
+ it("throws UnresolvedTokenError in strict mode when token is missing", () => {
88
+ const bp = makeBlueprint({
89
+ chatPrompt: "Hello {{name}}, today is {{date}}.",
90
+ });
91
+ expect(() =>
92
+ renderBlueprintPrompt(bp, { name: "Ada" }, { strict: true })
93
+ ).toThrow(UnresolvedTokenError);
94
+
95
+ try {
96
+ renderBlueprintPrompt(bp, { name: "Ada" }, { strict: true });
97
+ } catch (err) {
98
+ expect(err).toBeInstanceOf(UnresolvedTokenError);
99
+ expect((err as UnresolvedTokenError).tokens).toEqual(["date"]);
100
+ }
101
+ });
102
+
103
+ it("does not throw in strict mode when all tokens resolve", () => {
104
+ const bp = makeBlueprint({
105
+ chatPrompt: "Hello {{name}}.",
106
+ });
107
+ const out = renderBlueprintPrompt(
108
+ bp,
109
+ { name: "Ada" },
110
+ { strict: true }
111
+ );
112
+ expect(out.firstMessage).toBe("Hello Ada.");
113
+ });
114
+
115
+ it("strict mode treats empty-string values as resolved (not unresolved)", () => {
116
+ // Empty string is a resolved-to-empty substitution, distinct from an
117
+ // undefined variable which leaves {{token}} in the output string.
118
+ const bp = makeBlueprint({
119
+ chatPrompt: "Name: [{{name}}]",
120
+ });
121
+ const out = renderBlueprintPrompt(bp, { name: "" }, { strict: true });
122
+ expect(out.firstMessage).toBe("Name: []");
123
+ });
124
+ });
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Render a blueprint into a chat-ready first message.
3
+ *
4
+ * Pure template substitution over `blueprint.chatPrompt` with fallback to
5
+ * `blueprint.steps[0].promptTemplate` for the 13 existing built-ins that
6
+ * predate the `chatPrompt` field. Variable resolution is shared with the
7
+ * workflow engine via `resolveTemplate` so behavior stays consistent.
8
+ */
9
+
10
+ import type { WorkflowBlueprint } from "./types";
11
+ import { resolveTemplate } from "./template";
12
+
13
+ export interface RenderedBlueprintPrompt {
14
+ /** The seed message to pre-fill into the chat composer. */
15
+ firstMessage: string;
16
+ /** The conversation title, with variable substitution applied. */
17
+ title: string;
18
+ }
19
+
20
+ export interface RenderBlueprintPromptOptions {
21
+ /**
22
+ * Throw if any `{{token}}` references an undefined variable. Defaults to
23
+ * false — the underlying `resolveTemplate` substitutes undefined values with
24
+ * empty strings, which is the right behavior for optional blueprint vars.
25
+ * Set to `true` to validate that all referenced tokens were resolved.
26
+ */
27
+ strict?: boolean;
28
+ }
29
+
30
+ export class UnresolvedTokenError extends Error {
31
+ constructor(public readonly tokens: string[]) {
32
+ super(`Unresolved template tokens: ${tokens.join(", ")}`);
33
+ this.name = "UnresolvedTokenError";
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Render a blueprint's chat prompt and title with the provided parameters.
39
+ *
40
+ * Falls back to `steps[0].promptTemplate` if `chatPrompt` is absent. If the
41
+ * blueprint has no steps and no `chatPrompt`, returns an empty first message
42
+ * (the picker UI is expected to validate this case upstream).
43
+ */
44
+ export function renderBlueprintPrompt(
45
+ blueprint: WorkflowBlueprint,
46
+ params: Record<string, unknown>,
47
+ options: RenderBlueprintPromptOptions = {}
48
+ ): RenderedBlueprintPrompt {
49
+ const source =
50
+ blueprint.chatPrompt ?? blueprint.steps[0]?.promptTemplate ?? "";
51
+
52
+ if (options.strict) {
53
+ // Collect {{token}} names from the source (before resolveTemplate
54
+ // substitutes undefined with empty string). Skip `#if`/`/if` directives.
55
+ const unresolved = new Set<string>();
56
+ for (const combined of [source, blueprint.name]) {
57
+ const matches = combined.matchAll(/\{\{(\w+)\}\}/g);
58
+ for (const m of matches) {
59
+ if (!(m[1] in params)) unresolved.add(m[1]);
60
+ }
61
+ }
62
+ if (unresolved.size > 0) {
63
+ throw new UnresolvedTokenError([...unresolved]);
64
+ }
65
+ }
66
+
67
+ const firstMessage = resolveTemplate(source, params);
68
+ const title = resolveTemplate(blueprint.name, params);
69
+
70
+ return { firstMessage, title };
71
+ }
@@ -43,4 +43,10 @@ export interface WorkflowBlueprint {
43
43
  estimatedDuration?: string;
44
44
  difficulty?: "beginner" | "intermediate" | "advanced";
45
45
  isBuiltin?: boolean;
46
+ /**
47
+ * Optional chat-composer seed prompt. When present, `chat-conversation-templates`
48
+ * renders this (with variable substitution) into the first user message of a
49
+ * new conversation. When absent, consumers fall back to `steps[0].promptTemplate`.
50
+ */
51
+ chatPrompt?: string;
46
52
  }