harnery 0.32.0 → 0.33.0

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 (91) hide show
  1. package/README.md +2 -0
  2. package/dist/commander.d.ts.map +1 -1
  3. package/dist/commander.js +3 -1
  4. package/dist/commands/agents.d.ts.map +1 -1
  5. package/dist/commands/agents.js +212 -1
  6. package/dist/commands/claude-desktop.d.ts +2 -2
  7. package/dist/commands/claude-desktop.d.ts.map +1 -1
  8. package/dist/commands/claude-desktop.js +74 -3
  9. package/dist/commands/doctor.d.ts +4 -2
  10. package/dist/commands/doctor.d.ts.map +1 -1
  11. package/dist/commands/doctor.js +45 -6
  12. package/dist/commands/init.d.ts +1 -0
  13. package/dist/commands/init.d.ts.map +1 -1
  14. package/dist/commands/init.js +10 -0
  15. package/dist/commands/instructions.d.ts +4 -0
  16. package/dist/commands/instructions.d.ts.map +1 -0
  17. package/dist/commands/instructions.js +100 -0
  18. package/dist/commands/presence.d.ts.map +1 -1
  19. package/dist/commands/presence.js +4 -1
  20. package/dist/core/agents/canonical-emit.d.ts +1 -1
  21. package/dist/core/agents/canonical-emit.d.ts.map +1 -1
  22. package/dist/core/agents/canonical-emit.js +5 -3
  23. package/dist/core/agents/cli.js +5 -1
  24. package/dist/core/agents/coord-client.d.ts +11 -0
  25. package/dist/core/agents/coord-client.d.ts.map +1 -1
  26. package/dist/core/agents/index.d.ts +1 -0
  27. package/dist/core/agents/index.d.ts.map +1 -1
  28. package/dist/core/agents/index.js +1 -0
  29. package/dist/core/agents/render/prompt-context.d.ts +6 -0
  30. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  31. package/dist/core/agents/render/prompt-context.js +46 -5
  32. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  33. package/dist/core/agents/render/session-context.js +8 -2
  34. package/dist/core/agents/rules/stop-hook.js +4 -4
  35. package/dist/core/agents/state/heartbeat-projector.d.ts +11 -0
  36. package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
  37. package/dist/core/agents/state/heartbeat-projector.js +54 -0
  38. package/dist/core/agents/state/heartbeat-writer.d.ts +15 -0
  39. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  40. package/dist/core/agents/state/heartbeat-writer.js +32 -0
  41. package/dist/core/agents/state/session-state.d.ts +44 -0
  42. package/dist/core/agents/state/session-state.d.ts.map +1 -0
  43. package/dist/core/agents/state/session-state.js +81 -0
  44. package/dist/core/hooks/adapter/events.d.ts.map +1 -1
  45. package/dist/core/hooks/adapter/events.js +1 -0
  46. package/dist/core/hooks/adapter/parse.d.ts +1 -1
  47. package/dist/core/hooks/adapter/parse.d.ts.map +1 -1
  48. package/dist/core/hooks/adapter/parse.js +2 -0
  49. package/dist/core/hooks/cli.js +90 -6
  50. package/dist/core/hooks/codex-authorization.d.ts +30 -0
  51. package/dist/core/hooks/codex-authorization.d.ts.map +1 -0
  52. package/dist/core/hooks/codex-authorization.js +199 -0
  53. package/dist/core/hooks/events/schema.d.ts +30 -1
  54. package/dist/core/hooks/events/schema.d.ts.map +1 -1
  55. package/dist/core/presence/blob.d.ts +4 -0
  56. package/dist/core/presence/blob.d.ts.map +1 -1
  57. package/dist/core/presence/blob.js +12 -0
  58. package/dist/core/presence/index.js +15 -1
  59. package/dist/lib/claude-desktop.d.ts +47 -0
  60. package/dist/lib/claude-desktop.d.ts.map +1 -1
  61. package/dist/lib/claude-desktop.js +169 -1
  62. package/dist/lib/instructions/bundle.d.ts +33 -0
  63. package/dist/lib/instructions/bundle.d.ts.map +1 -0
  64. package/dist/lib/instructions/bundle.js +149 -0
  65. package/package.json +2 -2
  66. package/src/commander.ts +3 -1
  67. package/src/commands/agents.ts +248 -1
  68. package/src/commands/claude-desktop.ts +106 -5
  69. package/src/commands/doctor.ts +59 -7
  70. package/src/commands/init.ts +11 -0
  71. package/src/commands/instructions.ts +129 -0
  72. package/src/commands/presence.ts +7 -1
  73. package/src/core/agents/canonical-emit.ts +6 -4
  74. package/src/core/agents/cli.ts +6 -1
  75. package/src/core/agents/coord-client.ts +11 -0
  76. package/src/core/agents/index.ts +1 -0
  77. package/src/core/agents/render/prompt-context.ts +71 -6
  78. package/src/core/agents/render/session-context.ts +14 -2
  79. package/src/core/agents/rules/stop-hook.ts +5 -5
  80. package/src/core/agents/state/heartbeat-projector.ts +63 -0
  81. package/src/core/agents/state/heartbeat-writer.ts +51 -0
  82. package/src/core/agents/state/session-state.ts +131 -0
  83. package/src/core/hooks/adapter/events.ts +1 -0
  84. package/src/core/hooks/adapter/parse.ts +3 -0
  85. package/src/core/hooks/cli.ts +98 -7
  86. package/src/core/hooks/codex-authorization.ts +253 -0
  87. package/src/core/hooks/events/schema.ts +44 -0
  88. package/src/core/presence/blob.ts +18 -0
  89. package/src/core/presence/index.ts +18 -1
  90. package/src/lib/claude-desktop.ts +232 -1
  91. package/src/lib/instructions/bundle.ts +199 -0
@@ -0,0 +1,33 @@
1
+ import type { Adapter } from "../../core/hooks/events/schema.js";
2
+ export type InstructionComponentRole = "always_loaded" | "just_in_time" | "runtime_config";
3
+ export interface InstructionBundleComponent {
4
+ path: string;
5
+ role: InstructionComponentRole;
6
+ bytes: number;
7
+ sha256: string;
8
+ }
9
+ export interface InstructionBundle {
10
+ schema_version: 1;
11
+ adapter: Adapter;
12
+ coord_root: string;
13
+ profile_root: string;
14
+ instruction_roots: string[];
15
+ instruction_bundle_id: string;
16
+ canonical_source_id: string;
17
+ components: InstructionBundleComponent[];
18
+ canonical_sources: InstructionBundleComponent[];
19
+ }
20
+ interface BuildBundleOptions {
21
+ coordRoot: string;
22
+ cwd: string;
23
+ adapter: Adapter;
24
+ }
25
+ /**
26
+ * Build the content identity for the repo-authored instruction surface an
27
+ * adapter can load at one working path. The identity intentionally excludes
28
+ * timestamps and git revisions: two checkouts with byte-identical effective
29
+ * instructions belong to the same cohort.
30
+ */
31
+ export declare function buildInstructionBundle(options: BuildBundleOptions): InstructionBundle;
32
+ export {};
33
+ //# sourceMappingURL=bundle.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bundle.d.ts","sourceRoot":"","sources":["../../../src/lib/instructions/bundle.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,mCAAmC,CAAC;AAEjE,MAAM,MAAM,wBAAwB,GAAG,eAAe,GAAG,cAAc,GAAG,gBAAgB,CAAC;AAE3F,MAAM,WAAW,0BAA0B;IACzC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,wBAAwB,CAAC;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,iBAAiB;IAChC,cAAc,EAAE,CAAC,CAAC;IAClB,OAAO,EAAE,OAAO,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,MAAM,CAAC;IACrB,iBAAiB,EAAE,MAAM,EAAE,CAAC;IAC5B,qBAAqB,EAAE,MAAM,CAAC;IAC9B,mBAAmB,EAAE,MAAM,CAAC;IAC5B,UAAU,EAAE,0BAA0B,EAAE,CAAC;IACzC,iBAAiB,EAAE,0BAA0B,EAAE,CAAC;CACjD;AAED,UAAU,kBAAkB;IAC1B,SAAS,EAAE,MAAM,CAAC;IAClB,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,OAAO,CAAC;CAClB;AAOD;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,kBAAkB,GAAG,iBAAiB,CAyBrF"}
@@ -0,0 +1,149 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, lstatSync, readdirSync, readFileSync } from "node:fs";
3
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ /**
5
+ * Build the content identity for the repo-authored instruction surface an
6
+ * adapter can load at one working path. The identity intentionally excludes
7
+ * timestamps and git revisions: two checkouts with byte-identical effective
8
+ * instructions belong to the same cohort.
9
+ */
10
+ export function buildInstructionBundle(options) {
11
+ const coordRoot = resolve(options.coordRoot);
12
+ const cwd = resolve(options.cwd);
13
+ const roots = resolveInstructionRoots(coordRoot, cwd);
14
+ const profileRoot = roots.at(-1) ?? coordRoot;
15
+ const components = dedupeCandidates(roots.flatMap((root) => adapterCandidates(root, options.adapter)), coordRoot);
16
+ const canonicalSources = dedupeCandidates(roots.flatMap((root) => canonicalCandidates(root)), coordRoot);
17
+ return {
18
+ schema_version: 1,
19
+ adapter: options.adapter,
20
+ coord_root: coordRoot,
21
+ profile_root: relativePath(coordRoot, profileRoot),
22
+ instruction_roots: roots.map((root) => relativePath(coordRoot, root)),
23
+ instruction_bundle_id: identity("instruction-bundle-v1", options.adapter, components),
24
+ canonical_source_id: identity("instruction-source-v1", null, canonicalSources),
25
+ components,
26
+ canonical_sources: canonicalSources,
27
+ };
28
+ }
29
+ function resolveInstructionRoots(coordRoot, cwd) {
30
+ const roots = [coordRoot];
31
+ if (!isWithin(coordRoot, cwd))
32
+ return roots;
33
+ const ancestors = [];
34
+ let cursor = cwd;
35
+ while (cursor !== coordRoot) {
36
+ if (existsSync(join(cursor, "AGENTS.md")) || existsSync(join(cursor, "CLAUDE.md"))) {
37
+ ancestors.push(cursor);
38
+ }
39
+ const parent = dirname(cursor);
40
+ if (parent === cursor)
41
+ break;
42
+ cursor = parent;
43
+ }
44
+ roots.push(...ancestors.reverse().filter((root) => root !== coordRoot));
45
+ return roots;
46
+ }
47
+ function adapterCandidates(root, adapter) {
48
+ const candidates = [];
49
+ const addFile = (path, role) => {
50
+ if (existsSync(path) && lstatSync(path).isFile())
51
+ candidates.push({ absolutePath: path, role });
52
+ };
53
+ const addTree = (path, role) => {
54
+ for (const file of walkFiles(path))
55
+ candidates.push({ absolutePath: file, role });
56
+ };
57
+ if (adapter === "claude-code") {
58
+ addFile(join(root, "CLAUDE.md"), "always_loaded");
59
+ addTree(join(root, ".claude", "rules"), "always_loaded");
60
+ addTree(join(root, ".claude", "skills"), "just_in_time");
61
+ addFile(join(root, ".claude", "settings.json"), "runtime_config");
62
+ addFile(join(root, ".claude", "settings.local.json"), "runtime_config");
63
+ }
64
+ else if (adapter === "cursor") {
65
+ addFile(join(root, "AGENTS.md"), "always_loaded");
66
+ addTree(join(root, ".cursor", "rules"), "always_loaded");
67
+ addTree(join(root, ".agents", "skills"), "just_in_time");
68
+ addTree(join(root, ".cursor", "skills"), "just_in_time");
69
+ addFile(join(root, ".cursor", "hooks.json"), "runtime_config");
70
+ }
71
+ else {
72
+ addFile(join(root, "AGENTS.md"), "always_loaded");
73
+ addTree(join(root, ".harnery", "skills"), "just_in_time");
74
+ addFile(join(root, ".codex", "hooks.json"), "runtime_config");
75
+ }
76
+ // Harnery's host configuration governs hook enforcement for all adapters.
77
+ addFile(join(root, ".harnery", "config.jsonc"), "runtime_config");
78
+ return candidates;
79
+ }
80
+ function canonicalCandidates(root) {
81
+ const candidates = [];
82
+ const agents = join(root, "AGENTS.md");
83
+ if (existsSync(agents) && lstatSync(agents).isFile()) {
84
+ candidates.push({ absolutePath: agents, role: "always_loaded" });
85
+ }
86
+ for (const file of walkFiles(join(root, ".agents"))) {
87
+ candidates.push({ absolutePath: file, role: "just_in_time" });
88
+ }
89
+ return candidates;
90
+ }
91
+ function walkFiles(root) {
92
+ if (!existsSync(root) || !lstatSync(root).isDirectory())
93
+ return [];
94
+ const files = [];
95
+ const visit = (dir) => {
96
+ for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
97
+ const path = join(dir, entry.name);
98
+ if (entry.isDirectory())
99
+ visit(path);
100
+ else if (entry.isFile())
101
+ files.push(path);
102
+ }
103
+ };
104
+ visit(root);
105
+ return files;
106
+ }
107
+ function dedupeCandidates(candidates, coordRoot) {
108
+ const byPath = new Map();
109
+ for (const candidate of candidates) {
110
+ const key = resolve(candidate.absolutePath);
111
+ const prior = byPath.get(key);
112
+ if (!prior || roleRank(candidate.role) < roleRank(prior.role))
113
+ byPath.set(key, candidate);
114
+ }
115
+ return [...byPath.values()]
116
+ .map(({ absolutePath, role }) => {
117
+ const bytes = readFileSync(absolutePath);
118
+ return {
119
+ path: relativePath(coordRoot, absolutePath),
120
+ role,
121
+ bytes: bytes.byteLength,
122
+ sha256: createHash("sha256").update(bytes).digest("hex"),
123
+ };
124
+ })
125
+ .sort((a, b) => a.path.localeCompare(b.path) || a.role.localeCompare(b.role));
126
+ }
127
+ function identity(prefix, adapter, components) {
128
+ const body = components.map(({ path, role, sha256 }) => ({ path, role, sha256 }));
129
+ return `sha256:${createHash("sha256")
130
+ .update(JSON.stringify({ prefix, ...(adapter ? { adapter } : {}), components: body }))
131
+ .digest("hex")}`;
132
+ }
133
+ function relativePath(root, path) {
134
+ const rel = relative(root, path);
135
+ return rel === "" ? "." : rel.split(sep).join("/");
136
+ }
137
+ function isWithin(root, path) {
138
+ if (!isAbsolute(root) || !isAbsolute(path))
139
+ return false;
140
+ const rel = relative(root, path);
141
+ return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== "..");
142
+ }
143
+ function roleRank(role) {
144
+ if (role === "always_loaded")
145
+ return 0;
146
+ if (role === "runtime_config")
147
+ return 1;
148
+ return 2;
149
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harnery",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "description": "Multi-agent coordination for AI coding agents - Claude Code, Cursor, and Codex.",
5
5
  "license": "MIT",
6
6
  "author": "Ryan Kelly",
@@ -185,7 +185,7 @@
185
185
  "check:layering": "bun run scripts/check-layering.ts",
186
186
  "lint:fix": "bun x @biomejs/biome check --write src",
187
187
  "format": "bun x @biomejs/biome format --write src",
188
- "test": "bun test src tests",
188
+ "test": "bun run scripts/test.mjs",
189
189
  "test:web": "cd web && bun install && bun test",
190
190
  "test:integration": "bash tests/integration/run.sh",
191
191
  "smoke": "node scripts/smoke-test.mjs",
package/src/commander.ts CHANGED
@@ -44,6 +44,7 @@ import { registerFileHistoryCommand } from "./commands/file-history.ts";
44
44
  import { registerGovernorCommand } from "./commands/governor.ts";
45
45
  import { registerGrepCommand } from "./commands/grep.ts";
46
46
  import { registerInitCommand } from "./commands/init.ts";
47
+ import { registerInstructionsCommand } from "./commands/instructions.ts";
47
48
  import { registerJournalCommand } from "./commands/journal.ts";
48
49
  import { registerOutlineCommand } from "./commands/outline.ts";
49
50
  import { registerPolicyCommand } from "./commands/policy.ts";
@@ -294,10 +295,11 @@ export function createHarneryProgram(opts: HarneryContextOpts = {}): Command {
294
295
  registerAgentsCommand(program, emit, opts.context);
295
296
  registerCouncilCommands(program);
296
297
  registerDoctorCommand(program, emit);
298
+ registerInstructionsCommand(program, emit);
297
299
  registerInitCommand(program, emit, opts.binName);
298
300
  registerDeinitCommand(program, emit, opts.binName);
299
301
  registerBackupCommand(program, emit);
300
- registerClaudeDesktopCommand(program, emit);
302
+ registerClaudeDesktopCommand(program, emit, opts.context);
301
303
  registerSyncCommand(program, emit);
302
304
  registerWorkflowCommand(program, emit);
303
305
  registerWorkCommand(program, emit);
@@ -46,7 +46,15 @@ import {
46
46
  resolveOwnerBySessionEnv,
47
47
  resolveOwnerWithSource,
48
48
  } from "../core/agents/index.ts";
49
- import { buildSuggestedName } from "../core/agents/state/heartbeat-writer.ts";
49
+ import {
50
+ buildLifecycleSuggestedName,
51
+ buildSuggestedName,
52
+ } from "../core/agents/state/heartbeat-writer.ts";
53
+ import {
54
+ type AgentActivity,
55
+ foldSessionState,
56
+ type TaskState,
57
+ } from "../core/agents/state/session-state.ts";
50
58
  import { coordFreshnessSeconds, resolveBinName } from "../core/config.ts";
51
59
  import { type RemoteMachine, readRemoteMachines } from "../core/presence/index.ts";
52
60
  import { registerContextCommand } from "./context.ts";
@@ -182,6 +190,12 @@ interface Row {
182
190
  last_tool?: string | null;
183
191
  last_tool_target?: string | null;
184
192
  task?: string | null;
193
+ activity: AgentActivity;
194
+ activity_updated_at?: string | null;
195
+ activity_source?: string | null;
196
+ task_state: TaskState;
197
+ task_state_updated_at?: string | null;
198
+ task_state_reason?: string | null;
185
199
  turn_summary?: string | null;
186
200
  turn_summary_updated_at?: string | null;
187
201
  platform?: string | null;
@@ -190,6 +204,19 @@ interface Row {
190
204
  machine?: string | null;
191
205
  }
192
206
 
207
+ function activityOf(hb: Pick<Heartbeat, "activity">): AgentActivity {
208
+ return hb.activity ?? "unknown";
209
+ }
210
+
211
+ function taskStateOf(hb: Pick<Heartbeat, "task_state">): TaskState {
212
+ return hb.task_state ?? "active";
213
+ }
214
+
215
+ function lifecycleLabel(hb: Pick<Heartbeat, "task_state" | "task_state_reason">): string {
216
+ const state = taskStateOf(hb);
217
+ return state === "blocked" && hb.task_state_reason ? `${state}: ${hb.task_state_reason}` : state;
218
+ }
219
+
193
220
  let emit: EmitContext;
194
221
 
195
222
  export function registerAgentsCommand(
@@ -317,6 +344,18 @@ export function registerAgentsCommand(
317
344
  runSetTask(text.join(" "), opts);
318
345
  });
319
346
 
347
+ cmd
348
+ .command("lifecycle <state>")
349
+ .description("Declare this session's task lifecycle: active, blocked, or done")
350
+ .option("--reason <text>", "Explain the lifecycle declaration")
351
+ .option(
352
+ "--session-id <id>",
353
+ "Target the heartbeat with this session_id directly, bypassing the ppid walk.",
354
+ )
355
+ .action((state: string, opts: { reason?: string; sessionId?: string }) => {
356
+ runLifecycle(state, opts);
357
+ });
358
+
320
359
  cmd
321
360
  .command("release-claim <path>")
322
361
  .description(
@@ -901,6 +940,12 @@ function runWhoami(opts: { json?: boolean }): void {
901
940
  last_tool: hb.last_tool ?? null,
902
941
  last_tool_target: hb.last_tool_target ?? null,
903
942
  task: hb.task ?? null,
943
+ activity: activityOf(hb),
944
+ activity_updated_at: hb.activity_updated_at ?? null,
945
+ activity_source: hb.activity_source ?? null,
946
+ task_state: taskStateOf(hb),
947
+ task_state_updated_at: hb.task_state_updated_at ?? null,
948
+ task_state_reason: hb.task_state_reason ?? null,
904
949
  turn_summary: hb.turn_summary ?? null,
905
950
  turn_summary_updated_at: hb.turn_summary_updated_at ?? null,
906
951
  platform: hb.platform ?? "claude-code",
@@ -999,6 +1044,12 @@ function runList(opts: { all?: boolean; stale?: boolean; json?: boolean }): void
999
1044
  last_tool: h.last_tool ?? null,
1000
1045
  last_tool_target: h.last_tool_target ?? null,
1001
1046
  task: h.task ?? null,
1047
+ activity: activityOf(h),
1048
+ activity_updated_at: h.activity_updated_at ?? null,
1049
+ activity_source: h.activity_source ?? null,
1050
+ task_state: taskStateOf(h),
1051
+ task_state_updated_at: h.task_state_updated_at ?? null,
1052
+ task_state_reason: h.task_state_reason ?? null,
1002
1053
  turn_summary: h.turn_summary ?? null,
1003
1054
  turn_summary_updated_at: h.turn_summary_updated_at ?? null,
1004
1055
  platform: h.platform ?? "claude-code",
@@ -1024,6 +1075,12 @@ function runList(opts: { all?: boolean; stale?: boolean; json?: boolean }): void
1024
1075
  last_tool: h.last_tool ?? null,
1025
1076
  last_tool_target: h.last_tool_target ?? null,
1026
1077
  task: h.task ?? null,
1078
+ activity: activityOf(h),
1079
+ activity_updated_at: h.activity_updated_at ?? null,
1080
+ activity_source: h.activity_source ?? null,
1081
+ task_state: taskStateOf(h),
1082
+ task_state_updated_at: h.task_state_updated_at ?? null,
1083
+ task_state_reason: h.task_state_reason ?? null,
1027
1084
  turn_summary: h.turn_summary ?? null,
1028
1085
  turn_summary_updated_at: h.turn_summary_updated_at ?? null,
1029
1086
  platform: h.platform ?? "claude-code",
@@ -1052,6 +1109,12 @@ function runList(opts: { all?: boolean; stale?: boolean; json?: boolean }): void
1052
1109
  last_tool: a.last_tool ?? null,
1053
1110
  last_tool_target: null,
1054
1111
  task: a.task ?? null,
1112
+ activity: a.activity ?? "unknown",
1113
+ activity_updated_at: null,
1114
+ activity_source: null,
1115
+ task_state: a.task_state ?? "active",
1116
+ task_state_updated_at: null,
1117
+ task_state_reason: a.task_state_reason ?? null,
1055
1118
  turn_summary: a.turn_summary ?? null,
1056
1119
  turn_summary_updated_at: null,
1057
1120
  platform: a.platform ?? "claude-code",
@@ -1319,6 +1382,12 @@ async function runShow(name: string, opts: { json?: boolean }): Promise<void> {
1319
1382
  age_secs: ageSecs,
1320
1383
  last_heartbeat_secs_ago: heartbeatAgeSecs,
1321
1384
  task: hb.task ?? null,
1385
+ activity: activityOf(hb),
1386
+ activity_updated_at: hb.activity_updated_at ?? null,
1387
+ activity_source: hb.activity_source ?? null,
1388
+ task_state: taskStateOf(hb),
1389
+ task_state_updated_at: hb.task_state_updated_at ?? null,
1390
+ task_state_reason: hb.task_state_reason ?? null,
1322
1391
  turn_summary: hb.turn_summary ?? null,
1323
1392
  turn_summary_updated_at: hb.turn_summary_updated_at ?? null,
1324
1393
  title: report?.title ?? null,
@@ -1350,6 +1419,10 @@ async function runShow(name: string, opts: { json?: boolean }): Promise<void> {
1350
1419
  ` session ${formatAge(ageSecs)} old · kind=${data.kind} · session_id=${data.session_id.slice(0, 8)}…`,
1351
1420
  );
1352
1421
  lines.push(` last heartbeat: ${formatAge(heartbeatAgeSecs)} ago`);
1422
+ lines.push(` activity: ${data.activity}`);
1423
+ lines.push(
1424
+ ` lifecycle: ${data.task_state}${data.task_state === "blocked" && data.task_state_reason ? `: ${data.task_state_reason}` : ""}`,
1425
+ );
1353
1426
  if (data.last_tool) {
1354
1427
  const target = data.last_tool_target ? ` ${truncate(data.last_tool_target, 80)}` : "";
1355
1428
  lines.push(` last activity: ${data.last_tool}${target}`);
@@ -1526,6 +1599,11 @@ function runSetTask(task: string, opts?: { sessionId?: string }): void {
1526
1599
  // heartbeat-writer.setTask. This call is the naming call exactly when the
1527
1600
  // stamp appears across the mutation.
1528
1601
  const priorHb = readHeartbeat(myOwner);
1602
+ const normalizedTask = task.length > 0 ? task : undefined;
1603
+ const lifecycleWarning =
1604
+ priorHb && (priorHb.task_state ?? "active") !== "active" && priorHb.task !== normalizedTask
1605
+ ? `Task text changed while lifecycle is ${priorHb.task_state}; run \`${resolveBinName(root)} agents lifecycle active\` to reopen it explicitly.`
1606
+ : null;
1529
1607
 
1530
1608
  // Heartbeat mutation goes through agent-coord (atomic temp+rename).
1531
1609
  const helper = agentCoordOrExit(root);
@@ -1573,6 +1651,7 @@ function runSetTask(task: string, opts?: { sessionId?: string }): void {
1573
1651
  cleared: !task || task.length === 0,
1574
1652
  first_of_session: firstOfSession,
1575
1653
  suggested_session_name: suggestedName,
1654
+ ...(lifecycleWarning ? { warning: lifecycleWarning } : {}),
1576
1655
  // Right-time instruction: the UserPromptSubmit nudge fires before the name
1577
1656
  // exists; this result is the moment the model holds the string.
1578
1657
  ...(suggestedName
@@ -1583,6 +1662,147 @@ function runSetTask(task: string, opts?: { sessionId?: string }): void {
1583
1662
  });
1584
1663
  }
1585
1664
 
1665
+ function runLifecycle(rawState: string, opts: { reason?: string; sessionId?: string }): void {
1666
+ const state = rawState.trim().toLowerCase();
1667
+ if (state !== "active" && state !== "blocked" && state !== "done") {
1668
+ emit.error({
1669
+ code: "invalid_lifecycle_state",
1670
+ message: "lifecycle state must be one of: active, blocked, done",
1671
+ });
1672
+ process.exitCode = 1;
1673
+ return;
1674
+ }
1675
+
1676
+ const root = monorepoRoot();
1677
+ if (!root) {
1678
+ emit.error({
1679
+ code: "not_in_repo",
1680
+ message: "not in an agent session; coord_root() returned null",
1681
+ });
1682
+ process.exitCode = 1;
1683
+ return;
1684
+ }
1685
+ if (!opts.sessionId) ensureCursorSession(root);
1686
+ const myOwner = opts.sessionId ?? resolveOwner();
1687
+ if (!myOwner) {
1688
+ emit.error({
1689
+ code: "no_pidmap_entry",
1690
+ message:
1691
+ "not in an agent session; ppid walk found no pid-map entry (pass --session-id to bypass)",
1692
+ });
1693
+ process.exitCode = 1;
1694
+ return;
1695
+ }
1696
+
1697
+ const hb = readHeartbeat(myOwner);
1698
+ if (!hb) {
1699
+ emit.error({ code: "no_heartbeat", message: noHeartbeatMessage(myOwner) });
1700
+ process.exitCode = 1;
1701
+ return;
1702
+ }
1703
+ if (hb.kind === "subagent" || hb.kind === "transient" || hb.workflow_run_id) {
1704
+ emit.error({
1705
+ code: "lifecycle_not_human_facing",
1706
+ message: "task lifecycle declarations are limited to human-facing sessions",
1707
+ });
1708
+ process.exitCode = 1;
1709
+ return;
1710
+ }
1711
+
1712
+ const reason = opts.reason?.trim() || undefined;
1713
+ if (state === "blocked" && !reason) {
1714
+ emit.error({
1715
+ code: "blocked_reason_required",
1716
+ message: "lifecycle blocked requires --reason <text>",
1717
+ });
1718
+ process.exitCode = 1;
1719
+ return;
1720
+ }
1721
+ if (state === "done" && !hb.task) {
1722
+ emit.error({
1723
+ code: "task_required_for_done",
1724
+ message: "declare a current task with agents set-task before marking it done",
1725
+ });
1726
+ process.exitCode = 1;
1727
+ return;
1728
+ }
1729
+
1730
+ const priorState: TaskState = hb.task_state ?? "active";
1731
+ const priorReason = hb.task_state_reason || undefined;
1732
+ if (priorState === state && priorReason === reason) {
1733
+ emit.data({
1734
+ instance_id: myOwner,
1735
+ task_state: state,
1736
+ reason: reason ?? null,
1737
+ changed: false,
1738
+ name_reminted: false,
1739
+ git_finalization_checked: false,
1740
+ });
1741
+ return;
1742
+ }
1743
+
1744
+ let finalization: GitFinalizationResult | null = null;
1745
+ if (state === "done") {
1746
+ const history = readSessionWriteClaims(root, hb.instance_id, hb.session_id ?? myOwner);
1747
+ const touchedPaths = [...new Set([...(hb.files_touched ?? []), ...history.paths])];
1748
+ finalization = checkGitFinalization(root, touchedPaths, {
1749
+ claimHistoryComplete: history.complete,
1750
+ });
1751
+ if (!finalization.ok) {
1752
+ emit.error({
1753
+ code: "git_not_finalized",
1754
+ message: formatGitFinalizationFailure(finalization, resolveBinName(root)).replace(
1755
+ "the status box was not issued",
1756
+ "lifecycle was not changed",
1757
+ ),
1758
+ });
1759
+ process.exitCode = 1;
1760
+ return;
1761
+ }
1762
+ }
1763
+
1764
+ const suggestedName = buildLifecycleSuggestedName(hb.name ?? "unknown", hb.task, state);
1765
+ const nameReminted = suggestedName !== null && suggestedName !== hb.suggested_session_name;
1766
+ const emitted = emitCanonical({
1767
+ type: "state.task_state",
1768
+ owner: myOwner,
1769
+ session: hb.session_id ?? myOwner,
1770
+ adapter: normalizeAdapter(hb.platform),
1771
+ data: {
1772
+ state,
1773
+ reason: reason ?? null,
1774
+ prior_state: priorState,
1775
+ name_reminted: nameReminted,
1776
+ git_finalization_checked: finalization !== null,
1777
+ ...(nameReminted ? { suggested_session_name: suggestedName } : {}),
1778
+ },
1779
+ });
1780
+ if (!emitted) {
1781
+ emit.error({
1782
+ code: "lifecycle_event_failed",
1783
+ message: "the lifecycle event could not be recorded; heartbeat state was left unchanged",
1784
+ });
1785
+ process.exitCode = 1;
1786
+ return;
1787
+ }
1788
+
1789
+ emit.data({
1790
+ instance_id: myOwner,
1791
+ task_state: state,
1792
+ prior_state: priorState,
1793
+ reason: reason ?? null,
1794
+ changed: true,
1795
+ name_reminted: nameReminted,
1796
+ suggested_session_name: nameReminted ? suggestedName : null,
1797
+ git_finalization_checked: finalization !== null,
1798
+ ...(nameReminted
1799
+ ? {
1800
+ note: "Reproduce suggested_session_name verbatim, by itself, in a fenced code block at the top of your reply — the operator copies it as the session/tab title.",
1801
+ }
1802
+ : {}),
1803
+ });
1804
+ }
1805
+
1586
1806
  function runSuggestName(
1587
1807
  descriptionParts: string[],
1588
1808
  opts: { json?: boolean; sessionId?: string },
@@ -1790,6 +2010,12 @@ function runStatus(opts: { endTurn?: boolean; json?: boolean; sessionId?: string
1790
2010
  instance_id: hb.instance_id,
1791
2011
  kind: normalizeKind(hb.kind),
1792
2012
  session_age_secs: ageSecs,
2013
+ activity: activityOf(hb),
2014
+ activity_updated_at: hb.activity_updated_at ?? null,
2015
+ activity_source: hb.activity_source ?? null,
2016
+ task_state: taskStateOf(hb),
2017
+ task_state_updated_at: hb.task_state_updated_at ?? null,
2018
+ task_state_reason: hb.task_state_reason ?? null,
1793
2019
  files_held: filesHeld,
1794
2020
  peers_live: livePeers.length,
1795
2021
  peers_stale: peersStale,
@@ -1803,6 +2029,9 @@ function runStatus(opts: { endTurn?: boolean; json?: boolean; sessionId?: string
1803
2029
  agents: m.agents.map((a) => ({
1804
2030
  name: a.name || "unnamed",
1805
2031
  task: a.task ?? null,
2032
+ activity: a.activity ?? "unknown",
2033
+ task_state: a.task_state ?? "active",
2034
+ task_state_reason: a.task_state_reason ?? null,
1806
2035
  files: a.files_touched?.length ?? 0,
1807
2036
  })),
1808
2037
  })),
@@ -1822,6 +2051,8 @@ function runStatus(opts: { endTurn?: boolean; json?: boolean; sessionId?: string
1822
2051
 
1823
2052
  const rows: Array<[string, string]> = [
1824
2053
  ["session", formatAge(ageSecs)],
2054
+ ["activity", activityOf(hb)],
2055
+ ["lifecycle", lifecycleLabel(hb)],
1825
2056
  ["context", ctxStr],
1826
2057
  ["files", filesStr],
1827
2058
  ["peers", peersStr],
@@ -2573,6 +2804,12 @@ function traceLine(ev: CanonicalEvent, allTools: boolean): TraceEntry | null {
2573
2804
  case "state.task_set":
2574
2805
  detail = d.cleared ? "(cleared)" : clip(s("task"));
2575
2806
  break;
2807
+ case "state.task_state":
2808
+ detail = `${s("prior_state") || "active"} → ${s("state") || "active"}${s("reason") ? ` · ${clip(s("reason"), 55)}` : ""}`;
2809
+ break;
2810
+ case "interaction.input_requested":
2811
+ detail = s("request_kind") || s("tool_name") || "operator input requested";
2812
+ break;
2576
2813
  case "state.status_checked":
2577
2814
  detail = "status box rendered";
2578
2815
  break;
@@ -2693,6 +2930,7 @@ function runTrace(
2693
2930
  }
2694
2931
 
2695
2932
  const events = byId.get(resolvedId) ?? [];
2933
+ const state = foldSessionState(events, { instance_id: resolvedId });
2696
2934
  const lines = events
2697
2935
  .map((ev) => traceLine(ev, !!opts.allTools))
2698
2936
  .filter((l): l is TraceEntry => l !== null)
@@ -2707,6 +2945,12 @@ function runTrace(
2707
2945
  instance_id: resolvedId,
2708
2946
  other_instances: candidateIds.filter((id) => id !== resolvedId),
2709
2947
  total_events: events.length,
2948
+ activity: state.activity,
2949
+ activity_updated_at: state.activity_updated_at ?? null,
2950
+ activity_source: state.activity_source ?? null,
2951
+ task_state: state.task_state,
2952
+ task_state_updated_at: state.task_state_updated_at ?? null,
2953
+ task_state_reason: state.task_state_reason ?? null,
2710
2954
  shown: shown.length,
2711
2955
  entries: shown,
2712
2956
  };
@@ -2718,6 +2962,9 @@ function runTrace(
2718
2962
  emit.data(result);
2719
2963
  const header = `Trace: agent-${displayName} (${resolvedId.slice(0, 8)}…) ${events.length} events${result.other_instances.length ? ` · ${result.other_instances.length} older instance(s) of this name` : ""}`;
2720
2964
  process.stdout.write(`${header}\n`); // lint-ok-emission: human trace view
2965
+ process.stdout.write(
2966
+ ` activity=${state.activity} · lifecycle=${state.task_state}${state.task_state === "blocked" && state.task_state_reason ? `: ${state.task_state_reason}` : ""}\n`,
2967
+ ); // lint-ok-emission: human trace view
2721
2968
  if (shown.length === 0) {
2722
2969
  process.stdout.write(" (no events)\n"); // lint-ok-emission: human trace view
2723
2970
  return;