astrocode-workflow 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/LICENSE +1 -0
  2. package/README.md +85 -0
  3. package/dist/agents/commands.d.ts +9 -0
  4. package/dist/agents/commands.js +121 -0
  5. package/dist/agents/prompts.d.ts +2 -0
  6. package/dist/agents/prompts.js +27 -0
  7. package/dist/agents/registry.d.ts +6 -0
  8. package/dist/agents/registry.js +223 -0
  9. package/dist/agents/types.d.ts +14 -0
  10. package/dist/agents/types.js +8 -0
  11. package/dist/config/config-handler.d.ts +4 -0
  12. package/dist/config/config-handler.js +46 -0
  13. package/dist/config/defaults.d.ts +3 -0
  14. package/dist/config/defaults.js +3 -0
  15. package/dist/config/loader.d.ts +11 -0
  16. package/dist/config/loader.js +48 -0
  17. package/dist/config/schema.d.ts +176 -0
  18. package/dist/config/schema.js +198 -0
  19. package/dist/hooks/continuation-enforcer.d.ts +26 -0
  20. package/dist/hooks/continuation-enforcer.js +166 -0
  21. package/dist/hooks/tool-output-truncator.d.ts +17 -0
  22. package/dist/hooks/tool-output-truncator.js +56 -0
  23. package/dist/index.d.ts +3 -0
  24. package/dist/index.js +108 -0
  25. package/dist/shared/deep-merge.d.ts +8 -0
  26. package/dist/shared/deep-merge.js +25 -0
  27. package/dist/shared/hash.d.ts +1 -0
  28. package/dist/shared/hash.js +4 -0
  29. package/dist/shared/log.d.ts +7 -0
  30. package/dist/shared/log.js +24 -0
  31. package/dist/shared/model-tuning.d.ts +9 -0
  32. package/dist/shared/model-tuning.js +28 -0
  33. package/dist/shared/paths.d.ts +19 -0
  34. package/dist/shared/paths.js +51 -0
  35. package/dist/shared/text.d.ts +4 -0
  36. package/dist/shared/text.js +19 -0
  37. package/dist/shared/time.d.ts +1 -0
  38. package/dist/shared/time.js +3 -0
  39. package/dist/state/adapters/index.d.ts +39 -0
  40. package/dist/state/adapters/index.js +119 -0
  41. package/dist/state/db.d.ts +17 -0
  42. package/dist/state/db.js +83 -0
  43. package/dist/state/ids.d.ts +8 -0
  44. package/dist/state/ids.js +25 -0
  45. package/dist/state/schema.d.ts +2 -0
  46. package/dist/state/schema.js +247 -0
  47. package/dist/state/types.d.ts +70 -0
  48. package/dist/state/types.js +1 -0
  49. package/dist/tools/artifacts.d.ts +18 -0
  50. package/dist/tools/artifacts.js +71 -0
  51. package/dist/tools/index.d.ts +8 -0
  52. package/dist/tools/index.js +100 -0
  53. package/dist/tools/init.d.ts +8 -0
  54. package/dist/tools/init.js +41 -0
  55. package/dist/tools/injects.d.ts +23 -0
  56. package/dist/tools/injects.js +99 -0
  57. package/dist/tools/repair.d.ts +8 -0
  58. package/dist/tools/repair.js +25 -0
  59. package/dist/tools/run.d.ts +13 -0
  60. package/dist/tools/run.js +54 -0
  61. package/dist/tools/spec.d.ts +13 -0
  62. package/dist/tools/spec.js +41 -0
  63. package/dist/tools/stage.d.ts +23 -0
  64. package/dist/tools/stage.js +284 -0
  65. package/dist/tools/status.d.ts +8 -0
  66. package/dist/tools/status.js +107 -0
  67. package/dist/tools/story.d.ts +23 -0
  68. package/dist/tools/story.js +85 -0
  69. package/dist/tools/workflow.d.ts +8 -0
  70. package/dist/tools/workflow.js +197 -0
  71. package/dist/ui/inject.d.ts +5 -0
  72. package/dist/ui/inject.js +9 -0
  73. package/dist/ui/toasts.d.ts +13 -0
  74. package/dist/ui/toasts.js +39 -0
  75. package/dist/workflow/artifacts.d.ts +24 -0
  76. package/dist/workflow/artifacts.js +45 -0
  77. package/dist/workflow/baton.d.ts +66 -0
  78. package/dist/workflow/baton.js +101 -0
  79. package/dist/workflow/context.d.ts +12 -0
  80. package/dist/workflow/context.js +67 -0
  81. package/dist/workflow/directives.d.ts +37 -0
  82. package/dist/workflow/directives.js +111 -0
  83. package/dist/workflow/repair.d.ts +8 -0
  84. package/dist/workflow/repair.js +99 -0
  85. package/dist/workflow/state-machine.d.ts +43 -0
  86. package/dist/workflow/state-machine.js +127 -0
  87. package/dist/workflow/story-helpers.d.ts +9 -0
  88. package/dist/workflow/story-helpers.js +13 -0
  89. package/package.json +32 -0
  90. package/src/agents/commands.ts +137 -0
  91. package/src/agents/prompts.ts +28 -0
  92. package/src/agents/registry.ts +310 -0
  93. package/src/agents/types.ts +31 -0
  94. package/src/config/config-handler.ts +48 -0
  95. package/src/config/defaults.ts +4 -0
  96. package/src/config/loader.ts +55 -0
  97. package/src/config/schema.ts +236 -0
  98. package/src/hooks/continuation-enforcer.ts +217 -0
  99. package/src/hooks/tool-output-truncator.ts +82 -0
  100. package/src/index.ts +131 -0
  101. package/src/shared/deep-merge.ts +28 -0
  102. package/src/shared/hash.ts +5 -0
  103. package/src/shared/log.ts +30 -0
  104. package/src/shared/model-tuning.ts +48 -0
  105. package/src/shared/paths.ts +70 -0
  106. package/src/shared/text.ts +20 -0
  107. package/src/shared/time.ts +3 -0
  108. package/src/shims.node.d.ts +20 -0
  109. package/src/state/adapters/index.ts +155 -0
  110. package/src/state/db.ts +105 -0
  111. package/src/state/ids.ts +33 -0
  112. package/src/state/schema.ts +249 -0
  113. package/src/state/types.ts +76 -0
  114. package/src/tools/artifacts.ts +83 -0
  115. package/src/tools/index.ts +111 -0
  116. package/src/tools/init.ts +50 -0
  117. package/src/tools/injects.ts +108 -0
  118. package/src/tools/repair.ts +31 -0
  119. package/src/tools/run.ts +62 -0
  120. package/src/tools/spec.ts +50 -0
  121. package/src/tools/stage.ts +361 -0
  122. package/src/tools/status.ts +119 -0
  123. package/src/tools/story.ts +106 -0
  124. package/src/tools/workflow.ts +241 -0
  125. package/src/ui/inject.ts +13 -0
  126. package/src/ui/toasts.ts +48 -0
  127. package/src/workflow/artifacts.ts +69 -0
  128. package/src/workflow/baton.ts +141 -0
  129. package/src/workflow/context.ts +86 -0
  130. package/src/workflow/directives.ts +170 -0
  131. package/src/workflow/repair.ts +138 -0
  132. package/src/workflow/state-machine.ts +194 -0
  133. package/src/workflow/story-helpers.ts +18 -0
@@ -0,0 +1,236 @@
1
+ import { z } from "zod";
2
+
3
+ export const PermissionValueSchema = z.enum(["ask", "allow", "deny"]);
4
+ export type PermissionValue = z.infer<typeof PermissionValueSchema>;
5
+
6
+ const StageKeySchema = z.enum([
7
+ "frame",
8
+ "plan",
9
+ "spec",
10
+ "implement",
11
+ "review",
12
+ "verify",
13
+ "close",
14
+ ]);
15
+ export type StageKey = z.infer<typeof StageKeySchema>;
16
+
17
+ const InjectionModeSchema = z.enum(["visible", "silent"]);
18
+
19
+ const TruncationPolicySchema = z
20
+ .object({
21
+ enabled: z.boolean().default(true),
22
+ truncate_all_tool_outputs: z.boolean().default(false),
23
+ max_chars_default: z.number().int().positive().default(200_000),
24
+ max_chars_webfetch: z.number().int().positive().default(40_000),
25
+ max_chars_diff: z.number().int().positive().default(120_000),
26
+ persist_truncated_outputs: z.boolean().default(true),
27
+ })
28
+ .partial()
29
+ .default({});
30
+
31
+ const ContextCompactionSchema = z
32
+ .object({
33
+ enabled: z.boolean().default(true),
34
+ snapshot_after_stage_count: z.number().int().positive().default(2),
35
+ snapshot_max_lines: z.number().int().positive().default(60),
36
+ baton_summary_max_lines: z.number().int().positive().default(25),
37
+ inject_max_chars: z.number().int().positive().default(18_000),
38
+ })
39
+ .partial()
40
+ .default({});
41
+
42
+ const ContinuationSchema = z
43
+ .object({
44
+ enabled: z.boolean().default(true),
45
+ injection_mode: InjectionModeSchema.default("visible"),
46
+
47
+ inject_on_session_idle: z.boolean().default(true),
48
+ session_idle_ms: z.number().int().positive().default(12_000),
49
+
50
+ inject_on_tool_done_if_run_active: z.boolean().default(true),
51
+ inject_on_message_done_if_run_active: z.boolean().default(true),
52
+
53
+ dedupe_window_ms: z.number().int().positive().default(20_000),
54
+ max_same_directive_repeats: z.number().int().positive().default(1),
55
+
56
+ auto_continue: z.boolean().default(false),
57
+ auto_continue_delay_ms: z.number().int().positive().default(1500),
58
+ max_auto_steps_per_session: z.number().int().positive().default(50),
59
+ })
60
+ .partial()
61
+ .default({});
62
+
63
+ const ToastsSchema = z
64
+ .object({
65
+ enabled: z.boolean().default(true),
66
+ throttle_ms: z.number().int().positive().default(1500),
67
+ show_run_started: z.boolean().default(true),
68
+ show_stage_started: z.boolean().default(true),
69
+ show_stage_completed: z.boolean().default(true),
70
+ show_stage_failed: z.boolean().default(true),
71
+ show_run_completed: z.boolean().default(true),
72
+ show_auto_continue: z.boolean().default(true),
73
+ })
74
+ .partial()
75
+ .default({});
76
+
77
+ const DbSchema = z
78
+ .object({
79
+ path: z.string().default(".astro/astro.db"),
80
+ busy_timeout_ms: z.number().int().positive().default(5000),
81
+ pragmas: z
82
+ .object({
83
+ journal_mode: z.enum(["WAL", "DELETE"]).default("WAL"),
84
+ synchronous: z.enum(["NORMAL", "FULL", "OFF"]).default("NORMAL"),
85
+ foreign_keys: z.boolean().default(true),
86
+ temp_store: z.enum(["DEFAULT", "MEMORY", "FILE"]).default("MEMORY"),
87
+ })
88
+ // Why: Zod's .default({}) requires the *type* to accept an empty object.
89
+ // We still want runtime defaults for each key, so we make keys optional.
90
+ .partial()
91
+ .default({}),
92
+ schema_version_required: z.number().int().positive().default(2),
93
+ allow_auto_migrate: z.boolean().default(true),
94
+ fail_on_downgrade: z.boolean().default(true),
95
+ })
96
+ // Why: allow "db: {}" (or missing db) while still applying defaults.
97
+ .partial()
98
+ .default({});
99
+
100
+ const WorkflowSchema = z.object({
101
+ pipeline: z.array(StageKeySchema).default([
102
+ "frame",
103
+ "plan",
104
+ "spec",
105
+ "implement",
106
+ "review",
107
+ "verify",
108
+ "close",
109
+ ]),
110
+
111
+ default_mode: z.enum(["step", "loop"]).default("step"),
112
+ default_max_steps: z.number().int().positive().default(1),
113
+ loop_max_steps_hard_cap: z.number().int().positive().default(200),
114
+
115
+ plan_max_tasks: z.number().int().positive().default(7),
116
+ plan_max_lines: z.number().int().positive().default(80),
117
+
118
+ forbid_prompt_narration: z.boolean().default(true),
119
+ single_active_run_per_repo: z.boolean().default(true),
120
+ lock_timeout_ms: z.number().int().positive().default(4000),
121
+
122
+ role_first_subagents: z.boolean().default(true),
123
+
124
+ evidence_required: z
125
+ .object({
126
+ verify: z.boolean().default(true),
127
+ implement: z.boolean().default(false),
128
+ })
129
+ // NOTE: We want callers to be able to omit the whole object ("{}")
130
+ // while still receiving per-field defaults at parse time.
131
+ .partial()
132
+ .default({}),
133
+ }).partial().default({});
134
+
135
+ const ArtifactsSchema = z.object({
136
+ root_dir: z.string().default(".astro"),
137
+ runs_dir: z.string().default(".astro/runs"),
138
+ spec_path: z.string().default(".astro/spec.md"),
139
+
140
+ write_full_baton_md: z.boolean().default(true),
141
+ write_baton_summary_md: z.boolean().default(true),
142
+ write_baton_output_json: z.boolean().default(true),
143
+
144
+ baton_filename: z.string().default("baton.md"),
145
+ baton_summary_filename: z.string().default("baton.summary.md"),
146
+ baton_json_filename: z.string().default("baton.json"),
147
+ }).partial().default({});
148
+
149
+ const AgentsSchema = z.object({
150
+ // Display name for the *primary* agent tab.
151
+ orchestrator_name: z.string().default("Astro"),
152
+
153
+ // Display names for the stage sub-agents.
154
+ stage_agent_names: z
155
+ .object({
156
+ frame: z.string().default("Astro — Frame"),
157
+ plan: z.string().default("Astro — Plan"),
158
+ spec: z.string().default("Astro — Spec"),
159
+ implement: z.string().default("Astro — Implement"),
160
+ review: z.string().default("Astro — Review"),
161
+ verify: z.string().default("Astro — Verify"),
162
+ close: z.string().default("Astro — Close"),
163
+ })
164
+ .partial()
165
+ .default({}),
166
+
167
+ librarian_name: z.string().default("Astro — Librarian"),
168
+ explore_name: z.string().default("Astro — Explore"),
169
+
170
+ agent_variant_overrides: z
171
+ .record(
172
+ z.string(),
173
+ z.object({
174
+ variant: z.string().optional(),
175
+ model: z.string().optional(),
176
+ })
177
+ )
178
+ .default({}),
179
+ }).partial().default({});
180
+
181
+ const PermissionsSchema = z.object({
182
+ enforce_task_tool_restrictions: z.boolean().default(true),
183
+ deny_delegate_task_in_subagents: z.boolean().default(true),
184
+ }).partial().default({});
185
+
186
+ const GitSchema = z.object({
187
+ enabled: z.boolean().default(true),
188
+ allow_dirty_start: z.boolean().default(true),
189
+ auto_branch: z.boolean().default(true),
190
+ branch_prefix: z.string().default("astro/"),
191
+ auto_commit: z.boolean().default(false),
192
+ commit_message_template: z.string().default("astro: {{story_key}} {{title}}"),
193
+ persist_diff_artifacts: z.boolean().default(true),
194
+ }).partial().default({});
195
+
196
+ const UiSchema = z
197
+ .object({
198
+ toasts: ToastsSchema,
199
+ continue_prompt: z
200
+ .object({
201
+ enabled: z.boolean().default(true),
202
+ mode: z.enum(["toast_button", "popup", "chat_only"]).default("toast_button"),
203
+ idle_prompt_ms: z.number().int().positive().default(20_000),
204
+ })
205
+ .partial()
206
+ .default({}),
207
+ })
208
+ .partial()
209
+ .default({});
210
+
211
+ export const AstrocodeConfigSchema = z.object({
212
+ disabled_hooks: z.array(z.string()).default([]),
213
+ disabled_agents: z.array(z.string()).default([]),
214
+ disabled_commands: z.array(z.string()).default([]),
215
+
216
+ determinism: z
217
+ .object({
218
+ mode: z.enum(["on", "off"]).default("on"),
219
+ strict_stage_order: z.boolean().default(true),
220
+ })
221
+ .partial()
222
+ .default({}),
223
+
224
+ db: DbSchema,
225
+ workflow: WorkflowSchema,
226
+ continuation: ContinuationSchema,
227
+ truncation: TruncationPolicySchema,
228
+ context_compaction: ContextCompactionSchema,
229
+ artifacts: ArtifactsSchema,
230
+ agents: AgentsSchema,
231
+ permissions: PermissionsSchema,
232
+ git: GitSchema,
233
+ ui: UiSchema,
234
+ }).partial().default({});
235
+
236
+ export type AstrocodeConfig = z.infer<typeof AstrocodeConfigSchema>;
@@ -0,0 +1,217 @@
1
+ import type { AstrocodeConfig } from "../config/schema";
2
+ import type { SqliteDb } from "../state/db";
3
+ import { buildContextSnapshot } from "../workflow/context";
4
+ import { decideNextAction, getActiveRun } from "../workflow/state-machine";
5
+ import { buildContinueDirective, type BuiltDirective } from "../workflow/directives";
6
+ import { injectChatPrompt } from "../ui/inject";
7
+ import { nowISO } from "../shared/time";
8
+ import { createToastManager } from "../ui/toasts";
9
+
10
+ type SessionState = {
11
+ lastHash: string | null;
12
+ lastAtMs: number;
13
+ repeats: number;
14
+ autoSteps: number;
15
+ idleTimer: NodeJS.Timeout | null;
16
+ };
17
+
18
+ type ToolExecuteAfterInput = {
19
+ tool: string;
20
+ sessionID?: string;
21
+ };
22
+
23
+ type ChatMessageInput = {
24
+ sessionID: string;
25
+ agent: string;
26
+ };
27
+
28
+ type EventInput = {
29
+ event: { type: string; properties: any };
30
+ };
31
+
32
+ function msFromIso(iso: string): number {
33
+ const t = Date.parse(iso);
34
+ return Number.isFinite(t) ? t : 0;
35
+ }
36
+
37
+ export function createContinuationEnforcer(opts: {
38
+ ctx: any;
39
+ config: AstrocodeConfig;
40
+ db: SqliteDb;
41
+ }) {
42
+ const { ctx, config, db } = opts;
43
+
44
+ const toasts = createToastManager({ ctx, throttleMs: config.ui.toasts.throttle_ms });
45
+
46
+ const sessions = new Map<string, SessionState>();
47
+
48
+ function getState(sessionId: string): SessionState {
49
+ const cur = sessions.get(sessionId);
50
+ if (cur) return cur;
51
+ const state: SessionState = { lastHash: null, lastAtMs: 0, repeats: 0, autoSteps: 0, idleTimer: null };
52
+ sessions.set(sessionId, state);
53
+ return state;
54
+ }
55
+
56
+ function clearIdleTimer(sessionId: string) {
57
+ const s = getState(sessionId);
58
+ if (s.idleTimer) {
59
+ clearTimeout(s.idleTimer);
60
+ s.idleTimer = null;
61
+ }
62
+ }
63
+
64
+ function scheduleIdleInjection(sessionId: string) {
65
+ clearIdleTimer(sessionId);
66
+ if (!config.continuation.enabled) return;
67
+ if (!config.continuation.inject_on_session_idle) return;
68
+
69
+ const delay = config.continuation.session_idle_ms;
70
+
71
+ const s = getState(sessionId);
72
+ s.idleTimer = setTimeout(() => {
73
+ // Fire and forget
74
+ void maybeInjectContinue(sessionId, "idle_timer");
75
+ }, delay);
76
+ }
77
+
78
+ function shouldDedupe(sessionId: string, directive: BuiltDirective): boolean {
79
+ const s = getState(sessionId);
80
+ const now = Date.now();
81
+
82
+ // Memory window
83
+ if (s.lastHash === directive.hash && now - s.lastAtMs < config.continuation.dedupe_window_ms) {
84
+ if (s.repeats >= config.continuation.max_same_directive_repeats) return true;
85
+ }
86
+
87
+ // DB window (durable)
88
+ const cutoff = new Date(now - config.continuation.dedupe_window_ms).toISOString();
89
+ const row = db
90
+ .prepare(
91
+ "SELECT COUNT(*) as c FROM continuations WHERE session_id=? AND directive_hash=? AND created_at > ?"
92
+ )
93
+ .get(sessionId, directive.hash, cutoff) as { c: number } | undefined;
94
+
95
+ if ((row?.c ?? 0) >= config.continuation.max_same_directive_repeats) return true;
96
+
97
+ return false;
98
+ }
99
+
100
+ async function recordContinuation(sessionId: string, runId: string | null, directive: BuiltDirective, reason: string) {
101
+ db.prepare(
102
+ "INSERT INTO continuations (session_id, run_id, directive_hash, kind, reason, created_at) VALUES (?, ?, ?, ?, ?, ?)"
103
+ ).run(sessionId, runId, directive.hash, directive.kind, reason, nowISO());
104
+
105
+ const s = getState(sessionId);
106
+ const now = Date.now();
107
+ if (s.lastHash === directive.hash && now - s.lastAtMs < config.continuation.dedupe_window_ms) {
108
+ s.repeats += 1;
109
+ } else {
110
+ s.lastHash = directive.hash;
111
+ s.repeats = 1;
112
+ }
113
+ s.lastAtMs = now;
114
+ }
115
+
116
+ function formatNextAction(next: ReturnType<typeof decideNextAction>): string {
117
+ switch (next.kind) {
118
+ case "idle":
119
+ return "No approved stories. Queue/approve a story.";
120
+ case "start_run":
121
+ return `Start run for story ${next.story_key}.`;
122
+ case "delegate_stage":
123
+ return `Delegate stage ${next.stage_key}.`;
124
+ case "await_stage_completion":
125
+ return `Await stage ${next.stage_key} completion. If you have stage output, call astro_stage_complete.`;
126
+ case "complete_run":
127
+ return `Complete run ${next.run_id}.`;
128
+ case "failed":
129
+ return `Run failed at stage ${next.stage_key}: ${next.error_text}`;
130
+ default:
131
+ return "Continue.";
132
+ }
133
+ }
134
+
135
+ async function maybeInjectContinue(sessionId: string, reason: string) {
136
+ if (!config.continuation.enabled) return;
137
+
138
+ // Require active run
139
+ const active = getActiveRun(db);
140
+ if (!active) return;
141
+
142
+ const next = decideNextAction(db, config);
143
+
144
+ // If failed, don't auto-inject "continue" — surface via toast and stop.
145
+ if (next.kind === "failed") {
146
+ if (config.ui.toasts.enabled && config.ui.toasts.show_stage_failed) {
147
+ await toasts.show({ title: "Astrocode", message: `Run failed at ${next.stage_key}`, variant: "error" });
148
+ }
149
+ return;
150
+ }
151
+
152
+ const nextActionStr = formatNextAction(next);
153
+ const context = buildContextSnapshot({ db, config, run_id: active.run_id, next_action: nextActionStr });
154
+
155
+ const directive = buildContinueDirective({
156
+ config,
157
+ run_id: active.run_id,
158
+ stage_key: active.current_stage_key,
159
+ next_action: nextActionStr,
160
+ context_snapshot_md: context,
161
+ });
162
+
163
+ if (shouldDedupe(sessionId, directive)) return;
164
+
165
+ await recordContinuation(sessionId, active.run_id, directive, reason);
166
+
167
+ // Injection mode
168
+ if (config.continuation.injection_mode === "visible") {
169
+ await injectChatPrompt({ ctx, sessionId, text: directive.body });
170
+ } else {
171
+ // Silent mode is TODO: requires experimental.chat.messages.transform.
172
+ // For v2-alpha, we fall back to visible injection but mark it.
173
+ await injectChatPrompt({ ctx, sessionId, text: directive.body + "\n\n(Injected in silent mode fallback)" });
174
+ }
175
+
176
+ if (config.ui.toasts.enabled && config.ui.toasts.show_auto_continue) {
177
+ await toasts.show({ title: "Astrocode", message: "Continue directive injected", variant: "info" });
178
+ }
179
+ }
180
+
181
+ // Public hook handlers
182
+ return {
183
+ async onToolAfter(input: ToolExecuteAfterInput) {
184
+ const sessionId = input.sessionID ?? (ctx as any).sessionID;
185
+ if (!sessionId) return;
186
+ if (!config.continuation.inject_on_tool_done_if_run_active) return;
187
+
188
+ scheduleIdleInjection(sessionId);
189
+ },
190
+
191
+ async onChatMessage(_input: ChatMessageInput) {
192
+ if (!config.continuation.inject_on_message_done_if_run_active) return;
193
+ scheduleIdleInjection(_input.sessionID);
194
+ },
195
+
196
+ async onEvent(input: EventInput) {
197
+ const type = input.event.type;
198
+ const sessionId = input.event.properties?.sessionID;
199
+ if (!sessionId) return;
200
+
201
+ if (type === "session.idle") {
202
+ if (!config.continuation.inject_on_session_idle) return;
203
+ await maybeInjectContinue(sessionId, "session.idle");
204
+ }
205
+
206
+ if (type === "session.created") {
207
+ // When a session is created and there is an active run, nudge.
208
+ scheduleIdleInjection(sessionId);
209
+ }
210
+
211
+ if (type === "session.deleted") {
212
+ clearIdleTimer(sessionId);
213
+ sessions.delete(sessionId);
214
+ }
215
+ },
216
+ };
217
+ }
@@ -0,0 +1,82 @@
1
+ import path from "node:path";
2
+ import type { AstrocodeConfig } from "../config/schema";
3
+ import type { SqliteDb } from "../state/db";
4
+ import { getAstroPaths, runDir, ensureDir, toPosix } from "../shared/paths";
5
+ import { nowISO } from "../shared/time";
6
+ import { putArtifact } from "../workflow/artifacts";
7
+ import { sha256Hex } from "../shared/hash";
8
+ import { clampChars } from "../shared/text";
9
+ import { getActiveRun } from "../workflow/state-machine";
10
+
11
+ type ToolExecuteAfterInput = {
12
+ tool: string;
13
+ sessionID?: string;
14
+ };
15
+
16
+ type ToolExecuteAfterOutput = {
17
+ title?: string;
18
+ output?: string;
19
+ metadata?: Record<string, any>;
20
+ };
21
+
22
+ function pickMaxChars(toolName: string, cfg: AstrocodeConfig): number {
23
+ if (toolName.includes("webfetch") || toolName.includes("web.run")) return cfg.truncation.max_chars_webfetch;
24
+ if (toolName.includes("diff")) return cfg.truncation.max_chars_diff;
25
+ return cfg.truncation.max_chars_default;
26
+ }
27
+
28
+ export function createToolOutputTruncatorHook(opts: {
29
+ ctx: any;
30
+ config: AstrocodeConfig;
31
+ db: SqliteDb;
32
+ }) {
33
+ const { ctx, config, db } = opts;
34
+
35
+ return async function toolExecuteAfter(input: ToolExecuteAfterInput, output: ToolExecuteAfterOutput) {
36
+ if (!config.truncation.enabled) return;
37
+
38
+ const toolName = input.tool;
39
+ const text = output.output ?? "";
40
+ const maxChars = pickMaxChars(toolName, config);
41
+
42
+ if (!text || text.length <= maxChars) return;
43
+
44
+ const repoRoot = (ctx as any).directory as string;
45
+ const paths = getAstroPaths(repoRoot, config.db.path);
46
+ ensureDir(paths.toolOutputDir);
47
+
48
+ const active = getActiveRun(db);
49
+ const timestamp = nowISO().replace(/[:.]/g, "-");
50
+ const relBase =
51
+ active && config.truncation.persist_truncated_outputs
52
+ ? toPosix(path.join(".astro", "runs", active.run_id, "_tool_output"))
53
+ : toPosix(path.join(".astro", "tool_output"));
54
+
55
+ const relPath = toPosix(path.join(relBase, toolName.replace(/[^a-zA-Z0-9_-]/g, "_"), `${timestamp}.txt`));
56
+
57
+ const { artifact_id } = putArtifact({
58
+ repoRoot,
59
+ db,
60
+ run_id: active?.run_id ?? null,
61
+ stage_key: active?.current_stage_key ?? null,
62
+ type: "tool_output",
63
+ rel_path: relPath,
64
+ content: text,
65
+ meta: { tool: toolName, session_id: input.sessionID ?? null },
66
+ });
67
+
68
+ const digest = sha256Hex(text);
69
+ const head = clampChars(text, Math.min(maxChars, 4000));
70
+
71
+ output.output =
72
+ head +
73
+ `\n\n…(truncated; sha256=${digest})\n` +
74
+ `Full output saved: ${relPath}\n` +
75
+ `Artifact ID: ${artifact_id}`;
76
+
77
+ output.metadata = output.metadata ?? {};
78
+ output.metadata.truncated = true;
79
+ output.metadata.artifact_id = artifact_id;
80
+ output.metadata.full_output_path = relPath;
81
+ };
82
+ }
package/src/index.ts ADDED
@@ -0,0 +1,131 @@
1
+ import type { Plugin } from "@opencode-ai/plugin";
2
+ import { loadAstrocodeConfig } from "./config/loader";
3
+ import { createConfigHandler } from "./config/config-handler";
4
+ import { openSqlite, configurePragmas, ensureSchema } from "./state/db";
5
+ import { getAstroPaths, ensureAstroDirs } from "./shared/paths";
6
+ import { createAstroTools } from "./tools";
7
+ import { createContinuationEnforcer } from "./hooks/continuation-enforcer";
8
+ import { createToolOutputTruncatorHook } from "./hooks/tool-output-truncator";
9
+ import { createToastManager } from "./ui/toasts";
10
+ import { info, warn } from "./shared/log";
11
+
12
+ console.log("Astrocode plugin loading...");
13
+
14
+ const Astrocode: Plugin = async (ctx) => {
15
+ const repoRoot = ctx.directory as string;
16
+
17
+ // Always load config first - this provides defaults even in limited mode
18
+ let pluginConfig = loadAstrocodeConfig(repoRoot);
19
+
20
+ // Always ensure .astro directories exist, even in limited mode
21
+ const paths = getAstroPaths(repoRoot, pluginConfig.db.path);
22
+ ensureAstroDirs(paths);
23
+
24
+ let db: any = null;
25
+ let tools: any = null;
26
+ let configHandler: any = null;
27
+ let continuation: any = null;
28
+ let truncatorHook: any = null;
29
+ let toasts: any = null;
30
+
31
+ try {
32
+
33
+ db = openSqlite(paths.dbPath, { busyTimeoutMs: pluginConfig.db.busy_timeout_ms });
34
+ configurePragmas(db, pluginConfig.db.pragmas);
35
+ ensureSchema(db, { allowAutoMigrate: pluginConfig.db.allow_auto_migrate, failOnDowngrade: pluginConfig.db.fail_on_downgrade });
36
+
37
+ // Database initialized successfully
38
+ configHandler = createConfigHandler({ pluginConfig });
39
+ tools = createAstroTools({ ctx, config: pluginConfig, db });
40
+ continuation = createContinuationEnforcer({ ctx, config: pluginConfig, db });
41
+ truncatorHook = createToolOutputTruncatorHook({ ctx, config: pluginConfig, db });
42
+ toasts = createToastManager({ ctx, throttleMs: pluginConfig.ui.toasts.throttle_ms });
43
+ } catch (e) {
44
+ // Database initialization failed - setup limited mode
45
+
46
+ // Reload config to ensure all defaults are present
47
+ pluginConfig = loadAstrocodeConfig(repoRoot);
48
+
49
+ // Modify config for limited mode
50
+ pluginConfig.disabled_hooks = [...(pluginConfig.disabled_hooks || []), "continuation-enforcer", "tool-output-truncator"];
51
+ pluginConfig.ui.toasts.enabled = false;
52
+
53
+ // Create limited functionality
54
+ db = null;
55
+ configHandler = createConfigHandler({ pluginConfig });
56
+ tools = createAstroTools({ ctx, config: pluginConfig, db });
57
+ continuation = null;
58
+ truncatorHook = null;
59
+ toasts = null;
60
+ }
61
+
62
+ return {
63
+ name: "Astrocode",
64
+
65
+ // Merge agents + slash commands into system config
66
+ config: configHandler,
67
+
68
+ // Register tools
69
+ tool: tools,
70
+
71
+ // Limit created subagents from spawning more subagents (OMO-style).
72
+ "tool.execute.before": async (input: any, output: any) => {
73
+ if (!pluginConfig.permissions.enforce_task_tool_restrictions) return;
74
+ if (input.tool !== "task") return;
75
+
76
+ output.args = output.args ?? {};
77
+
78
+ const toolsMap = { ...(output.args.tools ?? {}) };
79
+
80
+ if (pluginConfig.permissions.deny_delegate_task_in_subagents) {
81
+ toolsMap.delegate_task = false;
82
+ }
83
+
84
+ output.args.tools = toolsMap;
85
+ },
86
+
87
+ "tool.execute.after": async (input: any, output: any) => {
88
+ // Truncate huge tool outputs to artifacts
89
+ if (truncatorHook && !pluginConfig.disabled_hooks.includes("tool-output-truncator")) {
90
+ await truncatorHook(input, output);
91
+ }
92
+
93
+ // Schedule continuation (do not immediately spam)
94
+ if (continuation && !pluginConfig.disabled_hooks.includes("continuation-enforcer")) {
95
+ await continuation.onToolAfter(input);
96
+ }
97
+ },
98
+
99
+ "chat.message": async (input: any, output: any) => {
100
+ if (continuation && !pluginConfig.disabled_hooks.includes("continuation-enforcer")) {
101
+ await continuation.onChatMessage(input);
102
+ }
103
+ return output;
104
+ },
105
+
106
+ event: async (input: any) => {
107
+ if (continuation && !pluginConfig.disabled_hooks.includes("continuation-enforcer")) {
108
+ await continuation.onEvent(input);
109
+ }
110
+ },
111
+
112
+ // Best-effort cleanup
113
+ close: async () => {
114
+ try {
115
+ db.close();
116
+ } catch {
117
+ // ignore
118
+ }
119
+
120
+ if (toasts && pluginConfig.ui.toasts.enabled) {
121
+ try {
122
+ await toasts.show({ title: "Astrocode", message: "Plugin closed", variant: "info" });
123
+ } catch {
124
+ // ignore
125
+ }
126
+ }
127
+ },
128
+ };
129
+ };
130
+
131
+ export default Astrocode;
@@ -0,0 +1,28 @@
1
+ export function isPlainObject(v: unknown): v is Record<string, unknown> {
2
+ return typeof v === "object" && v !== null && !Array.isArray(v);
3
+ }
4
+
5
+ /**
6
+ * Deep merge:
7
+ * - objects merge recursively
8
+ * - arrays replace
9
+ * - primitives overwrite
10
+ */
11
+ export function deepMerge<T>(base: T, patch: Partial<T>): T {
12
+ if (!isPlainObject(base) || !isPlainObject(patch)) {
13
+ return (patch as T) ?? base;
14
+ }
15
+
16
+ const out: Record<string, unknown> = { ...(base as Record<string, unknown>) };
17
+
18
+ for (const [k, v] of Object.entries(patch as Record<string, unknown>)) {
19
+ const cur = out[k];
20
+ if (isPlainObject(cur) && isPlainObject(v)) {
21
+ out[k] = deepMerge(cur, v);
22
+ } else {
23
+ out[k] = v;
24
+ }
25
+ }
26
+
27
+ return out as T;
28
+ }
@@ -0,0 +1,5 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ export function sha256Hex(input: string | Buffer): string {
4
+ return createHash("sha256").update(input).digest("hex");
5
+ }