@rubytech/create-maxy-code 0.1.216 → 0.1.221

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 (82) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/lib/mcp-eager/dist/index.d.ts +0 -55
  3. package/payload/platform/lib/mcp-eager/dist/index.d.ts.map +1 -1
  4. package/payload/platform/lib/mcp-eager/dist/index.js +0 -76
  5. package/payload/platform/lib/mcp-eager/dist/index.js.map +1 -1
  6. package/payload/platform/lib/mcp-eager/src/index.ts +0 -101
  7. package/payload/platform/plugins/admin/PLUGIN.md +2 -3
  8. package/payload/platform/plugins/admin/hooks/__tests__/hook-emit-stale-lock-ttl.test.sh +102 -0
  9. package/payload/platform/plugins/admin/hooks/archive-ingest-surface-gate.sh +1 -2
  10. package/payload/platform/plugins/admin/hooks/lib/hook-emit.sh +32 -3
  11. package/payload/platform/plugins/admin/hooks/post-tool-use-agent.sh +20 -1
  12. package/payload/platform/plugins/admin/mcp/dist/index.js +2 -2
  13. package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
  14. package/payload/platform/plugins/admin/skills/admin-user-management/SKILL.md +1 -1
  15. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +18 -18
  16. package/payload/platform/plugins/admin/skills/plugin-management/SKILL.md +2 -2
  17. package/payload/platform/plugins/browser/mcp/dist/index.js +19 -19
  18. package/payload/platform/plugins/browser/mcp/dist/index.js.map +1 -1
  19. package/payload/platform/plugins/docs/references/platform.md +10 -17
  20. package/payload/platform/plugins/docs/references/troubleshooting.md +7 -0
  21. package/payload/platform/plugins/email/mcp/dist/index.js +12 -12
  22. package/payload/platform/plugins/email/mcp/dist/index.js.map +1 -1
  23. package/payload/platform/plugins/memory/mcp/dist/index.js +8 -8
  24. package/payload/platform/plugins/memory/mcp/dist/index.js.map +1 -1
  25. package/payload/platform/plugins/memory/references/graph-primitives.md +2 -2
  26. package/payload/platform/plugins/url-get/mcp/dist/index.js +2 -2
  27. package/payload/platform/plugins/url-get/mcp/dist/index.js.map +1 -1
  28. package/payload/platform/scripts/check-no-esm-require.mjs +3 -0
  29. package/payload/platform/scripts/setup-account.sh +0 -15
  30. package/payload/platform/services/claude-session-manager/dist/auth-snapshot.d.ts +4 -0
  31. package/payload/platform/services/claude-session-manager/dist/auth-snapshot.d.ts.map +1 -0
  32. package/payload/platform/services/claude-session-manager/dist/auth-snapshot.js +50 -0
  33. package/payload/platform/services/claude-session-manager/dist/auth-snapshot.js.map +1 -0
  34. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts +0 -14
  35. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  36. package/payload/platform/services/claude-session-manager/dist/http-server.js +44 -71
  37. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  38. package/payload/platform/services/claude-session-manager/dist/index.js +12 -31
  39. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  40. package/payload/platform/services/claude-session-manager/dist/jsonl-tail.d.ts +3 -0
  41. package/payload/platform/services/claude-session-manager/dist/jsonl-tail.d.ts.map +1 -1
  42. package/payload/platform/services/claude-session-manager/dist/jsonl-tail.js +55 -1
  43. package/payload/platform/services/claude-session-manager/dist/jsonl-tail.js.map +1 -1
  44. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +1 -1
  45. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  46. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +27 -20
  47. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  48. package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts +23 -17
  49. package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts.map +1 -1
  50. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js +94 -73
  51. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js.map +1 -1
  52. package/payload/platform/services/claude-session-manager/dist/rc-life.d.ts +4 -0
  53. package/payload/platform/services/claude-session-manager/dist/rc-life.d.ts.map +1 -0
  54. package/payload/platform/services/claude-session-manager/dist/rc-life.js +16 -0
  55. package/payload/platform/services/claude-session-manager/dist/rc-life.js.map +1 -0
  56. package/payload/platform/services/claude-session-manager/dist/systemd-scope.d.ts +12 -46
  57. package/payload/platform/services/claude-session-manager/dist/systemd-scope.d.ts.map +1 -1
  58. package/payload/platform/services/claude-session-manager/dist/systemd-scope.js +24 -73
  59. package/payload/platform/services/claude-session-manager/dist/systemd-scope.js.map +1 -1
  60. package/payload/platform/templates/agents/admin/IDENTITY.md +3 -3
  61. package/payload/premium-plugins/writer-craft/mcp/dist/index.d.ts.map +1 -1
  62. package/payload/premium-plugins/writer-craft/mcp/dist/index.js +4 -5
  63. package/payload/premium-plugins/writer-craft/mcp/dist/index.js.map +1 -1
  64. package/payload/premium-plugins/writer-craft/mcp/src/index.ts +4 -5
  65. package/payload/server/public/assets/AdminShell-BcxEZnpu.js +1 -0
  66. package/payload/server/public/assets/{admin-DjGGOx2K.js → admin-DMo5DWKH.js} +1 -1
  67. package/payload/server/public/assets/{data-WBEvtRV-.js → data-D6IQdm0R.js} +1 -1
  68. package/payload/server/public/assets/{graph-DsOLsIPf.js → graph-PkZKVkWD.js} +1 -1
  69. package/payload/server/public/data.html +2 -2
  70. package/payload/server/public/graph.html +2 -2
  71. package/payload/server/public/index.html +2 -2
  72. package/payload/server/server.js +49 -12
  73. package/payload/platform/plugins/admin/hooks/__tests__/pre-tool-use-admin-tool-gate.test.sh +0 -169
  74. package/payload/platform/plugins/admin/hooks/__tests__/pre-tool-use-base64-guard.test.sh +0 -204
  75. package/payload/platform/plugins/admin/hooks/__tests__/pre-tool-use-memory-write-passthrough.test.sh +0 -118
  76. package/payload/platform/plugins/admin/hooks/pre-tool-use.sh +0 -409
  77. package/payload/premium-plugins/writer-craft/lib/mcp-eager/dist/index.d.ts +0 -116
  78. package/payload/premium-plugins/writer-craft/lib/mcp-eager/dist/index.d.ts.map +0 -1
  79. package/payload/premium-plugins/writer-craft/lib/mcp-eager/dist/index.js +0 -125
  80. package/payload/premium-plugins/writer-craft/lib/mcp-eager/dist/index.js.map +0 -1
  81. package/payload/premium-plugins/writer-craft/lib/mcp-eager/package.json +0 -7
  82. package/payload/server/public/assets/AdminShell-CPWbJ_I8.js +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy-code",
3
- "version": "0.1.216",
3
+ "version": "0.1.221",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy-code": "./dist/index.js"
@@ -20,41 +20,6 @@
20
20
  */
21
21
  /** Wire-format metadata key the Claude Code SDK reads. */
22
22
  export declare const ALWAYS_LOAD_META_KEY: "anthropic/alwaysLoad";
23
- /**
24
- * Admin-seat detection. The rc-daemon exports `MAXY_SESSION_ROLE=admin` and
25
- * `MAXY_SPECIALIST=""` for the orchestrator process and its MCP plugin
26
- * children; specialist spawns set `MAXY_SPECIALIST=<name>`. A plugin process
27
- * inheriting the env can therefore decide at registration AND invocation
28
- * time whether it is loaded inside the admin seat.
29
- *
30
- * Mirrors `isAdminSeat` in platform/plugins/admin/mcp/src/skill-resolution.ts
31
- * — this is a duplicate at the library layer so plugins that cannot import
32
- * from the admin plugin (a sibling, not a dependency) share the same rule.
33
- */
34
- export declare function isAdminSeat(env?: NodeJS.ProcessEnv): boolean;
35
- /**
36
- * MCP refusal envelope returned when the admin seat invokes a specialist-
37
- * owned tool. The text names the dispatch route so the model's next action
38
- * is the `Agent` dispatch rather than a dead end.
39
- */
40
- export declare function adminBlockedResponse(toolName: string, owner: string): {
41
- content: {
42
- type: "text";
43
- text: string;
44
- }[];
45
- isError: true;
46
- };
47
- /**
48
- * Wrap a tool handler so any admin-seat invocation short-circuits with the
49
- * refusal envelope above and emits one `[admin-tool-gate]` line to stderr.
50
- *
51
- * Specialist seats pass through unchanged: this is the primitive Task 529
52
- * defines for closing the in-message authoring path that Task 516 could
53
- * not reach. The handler signature is preserved so existing call sites
54
- * substitute `eagerTool(... handler)` for `eagerTool(... withAdminGate(name,
55
- * owner, handler))` mechanically.
56
- */
57
- export declare function withAdminGate<H extends (...args: any[]) => any>(toolName: string, owner: string, handler: H): H;
58
23
  /**
59
24
  * Loose structural type for any object exposing a `registerTool` method. The
60
25
  * MCP SDK ships dual ESM/CJS packages with conditional exports, so importing
@@ -92,25 +57,5 @@ type LocalZodRawShape = Record<string, unknown>;
92
57
  * the consumer's own zod version) infers the handler args.
93
58
  */
94
59
  export declare function eagerTool<Args extends LocalZodRawShape>(server: EagerCapableServer, name: string, description: string, inputSchema: Args, handler: (...args: any[]) => any): void;
95
- /**
96
- * Loose structural type for the legacy four-arg `server.tool(name, desc,
97
- * schema, handler)` call shape — used when the admin seat must register a
98
- * specialist-owned tool as deferred (not eager) so it does not appear in
99
- * the admin's eager tool list.
100
- */
101
- type DeferredCapableServer = {
102
- tool: (name: string, description: string, inputSchema: any, handler: (...args: any[]) => any) => any;
103
- };
104
- /**
105
- * Register a tool that is eager for specialists but deferred + admin-gated
106
- * for the admin seat. The owning specialist name is named here so the
107
- * refusal envelope (and the `[admin-tool-gate]` log line) carries it.
108
- *
109
- * Use for every tool whose owner is a specialist (database-operator, writer-
110
- * craft, research-assistant, personal-assistant, content-producer). The
111
- * admin seat sees the tool only via ToolSearch — and any invocation from the
112
- * admin seat refuses with the dispatch hint.
113
- */
114
- export declare function eagerToolGated<Args extends LocalZodRawShape>(server: EagerCapableServer & DeferredCapableServer, name: string, owner: string, description: string, inputSchema: Args, handler: (...args: any[]) => any): void;
115
60
  export {};
116
61
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,0DAA0D;AAC1D,eAAO,MAAM,oBAAoB,EAAG,sBAA+B,CAAC;AAEpE;;;;;;;;;;GAUG;AACH,wBAAgB,WAAW,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,OAAO,CAEzE;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG;IACrE,OAAO,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IAC1C,OAAO,EAAE,IAAI,CAAC;CACf,CAYA;AAED;;;;;;;;;GASG;AAEH,wBAAgB,aAAa,CAAC,CAAC,SAAS,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,GAAG,EAC7D,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,CAAC,GACT,CAAC,CAYH;AAED;;;;;;;GAOG;AAEH,KAAK,kBAAkB,GAAG;IAAE,YAAY,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,GAAG,CAAA;CAAE,CAAC;AAEpE;;;;;;;;;;GAUG;AACH,KAAK,gBAAgB,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAEhD;;;;;;;;;;;;GAYG;AACH,wBAAgB,SAAS,CAAC,IAAI,SAAS,gBAAgB,EACrD,MAAM,EAAE,kBAAkB,EAC1B,IAAI,EAAE,MAAM,EACZ,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,IAAI,EAEjB,OAAO,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,GAAG,GAC/B,IAAI,CAUN;AAED;;;;;GAKG;AAEH,KAAK,qBAAqB,GAAG;IAAE,IAAI,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,WAAW,EAAE,GAAG,EAAE,OAAO,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,GAAG,KAAK,GAAG,CAAA;CAAE,CAAC;AAEtI;;;;;;;;;GASG;AACH,wBAAgB,cAAc,CAAC,IAAI,SAAS,gBAAgB,EAC1D,MAAM,EAAE,kBAAkB,GAAG,qBAAqB,EAClD,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,EACb,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,IAAI,EAEjB,OAAO,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,GAAG,GAC/B,IAAI,CAMN"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,0DAA0D;AAC1D,eAAO,MAAM,oBAAoB,EAAG,sBAA+B,CAAC;AAEpE;;;;;;;GAOG;AAEH,KAAK,kBAAkB,GAAG;IAAE,YAAY,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,GAAG,CAAA;CAAE,CAAC;AAEpE;;;;;;;;;;GAUG;AACH,KAAK,gBAAgB,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAEhD;;;;;;;;;;;;GAYG;AACH,wBAAgB,SAAS,CAAC,IAAI,SAAS,gBAAgB,EACrD,MAAM,EAAE,kBAAkB,EAC1B,IAAI,EAAE,MAAM,EACZ,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,IAAI,EAEjB,OAAO,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,GAAG,GAC/B,IAAI,CAUN"}
@@ -1,11 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ALWAYS_LOAD_META_KEY = void 0;
4
- exports.isAdminSeat = isAdminSeat;
5
- exports.adminBlockedResponse = adminBlockedResponse;
6
- exports.withAdminGate = withAdminGate;
7
4
  exports.eagerTool = eagerTool;
8
- exports.eagerToolGated = eagerToolGated;
9
5
  /**
10
6
  * MCP eager-load helper.
11
7
  *
@@ -28,59 +24,6 @@ exports.eagerToolGated = eagerToolGated;
28
24
  */
29
25
  /** Wire-format metadata key the Claude Code SDK reads. */
30
26
  exports.ALWAYS_LOAD_META_KEY = "anthropic/alwaysLoad";
31
- /**
32
- * Admin-seat detection. The rc-daemon exports `MAXY_SESSION_ROLE=admin` and
33
- * `MAXY_SPECIALIST=""` for the orchestrator process and its MCP plugin
34
- * children; specialist spawns set `MAXY_SPECIALIST=<name>`. A plugin process
35
- * inheriting the env can therefore decide at registration AND invocation
36
- * time whether it is loaded inside the admin seat.
37
- *
38
- * Mirrors `isAdminSeat` in platform/plugins/admin/mcp/src/skill-resolution.ts
39
- * — this is a duplicate at the library layer so plugins that cannot import
40
- * from the admin plugin (a sibling, not a dependency) share the same rule.
41
- */
42
- function isAdminSeat(env = process.env) {
43
- return env.MAXY_SESSION_ROLE === "admin" && !(env.MAXY_SPECIALIST && env.MAXY_SPECIALIST.length > 0);
44
- }
45
- /**
46
- * MCP refusal envelope returned when the admin seat invokes a specialist-
47
- * owned tool. The text names the dispatch route so the model's next action
48
- * is the `Agent` dispatch rather than a dead end.
49
- */
50
- function adminBlockedResponse(toolName, owner) {
51
- return {
52
- content: [
53
- {
54
- type: "text",
55
- text: `Tool \`${toolName}\` is owned by the \`${owner}\` specialist and is not callable from the admin seat. ` +
56
- `Dispatch \`${owner}\` with the Agent tool and pass the work as the brief.`,
57
- },
58
- ],
59
- isError: true,
60
- };
61
- }
62
- /**
63
- * Wrap a tool handler so any admin-seat invocation short-circuits with the
64
- * refusal envelope above and emits one `[admin-tool-gate]` line to stderr.
65
- *
66
- * Specialist seats pass through unchanged: this is the primitive Task 529
67
- * defines for closing the in-message authoring path that Task 516 could
68
- * not reach. The handler signature is preserved so existing call sites
69
- * substitute `eagerTool(... handler)` for `eagerTool(... withAdminGate(name,
70
- * owner, handler))` mechanically.
71
- */
72
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
73
- function withAdminGate(toolName, owner, handler) {
74
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
75
- const wrapped = ((...args) => {
76
- if (isAdminSeat(process.env)) {
77
- process.stderr.write(`[admin-tool-gate] role=admin tool=${toolName} decision=block reason=specialist-owned owner=${owner}\n`);
78
- return adminBlockedResponse(toolName, owner);
79
- }
80
- return handler(...args);
81
- });
82
- return wrapped;
83
- }
84
27
  /**
85
28
  * Register an MCP tool that the Claude Code SDK eager-loads into the
86
29
  * model's prompt at session boot, bypassing the ToolSearch round-trip.
@@ -103,23 +46,4 @@ handler) {
103
46
  _meta: { [exports.ALWAYS_LOAD_META_KEY]: true },
104
47
  }, handler);
105
48
  }
106
- /**
107
- * Register a tool that is eager for specialists but deferred + admin-gated
108
- * for the admin seat. The owning specialist name is named here so the
109
- * refusal envelope (and the `[admin-tool-gate]` log line) carries it.
110
- *
111
- * Use for every tool whose owner is a specialist (database-operator, writer-
112
- * craft, research-assistant, personal-assistant, content-producer). The
113
- * admin seat sees the tool only via ToolSearch — and any invocation from the
114
- * admin seat refuses with the dispatch hint.
115
- */
116
- function eagerToolGated(server, name, owner, description, inputSchema,
117
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
118
- handler) {
119
- if (isAdminSeat(process.env)) {
120
- server.tool(name, description, inputSchema, withAdminGate(name, owner, handler));
121
- return;
122
- }
123
- eagerTool(server, name, description, inputSchema, handler);
124
- }
125
49
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;AAkCA,kCAEC;AAOD,oDAeC;AAaD,sCAgBC;AAuCD,8BAiBC;AAqBD,wCAcC;AAlLD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,0DAA0D;AAC7C,QAAA,oBAAoB,GAAG,sBAA+B,CAAC;AAEpE;;;;;;;;;;GAUG;AACH,SAAgB,WAAW,CAAC,MAAyB,OAAO,CAAC,GAAG;IAC9D,OAAO,GAAG,CAAC,iBAAiB,KAAK,OAAO,IAAI,CAAC,CAAC,GAAG,CAAC,eAAe,IAAI,GAAG,CAAC,eAAe,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;AACvG,CAAC;AAED;;;;GAIG;AACH,SAAgB,oBAAoB,CAAC,QAAgB,EAAE,KAAa;IAIlE,OAAO;QACL,OAAO,EAAE;YACP;gBACE,IAAI,EAAE,MAAM;gBACZ,IAAI,EACF,UAAU,QAAQ,wBAAwB,KAAK,yDAAyD;oBACxG,cAAc,KAAK,wDAAwD;aAC9E;SACF;QACD,OAAO,EAAE,IAAI;KACd,CAAC;AACJ,CAAC;AAED;;;;;;;;;GASG;AACH,8DAA8D;AAC9D,SAAgB,aAAa,CAC3B,QAAgB,EAChB,KAAa,EACb,OAAU;IAEV,8DAA8D;IAC9D,MAAM,OAAO,GAAG,CAAC,CAAC,GAAG,IAAW,EAAE,EAAE;QAClC,IAAI,WAAW,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC;YAC7B,OAAO,CAAC,MAAM,CAAC,KAAK,CAClB,qCAAqC,QAAQ,iDAAiD,KAAK,IAAI,CACxG,CAAC;YACF,OAAO,oBAAoB,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAC;QAC/C,CAAC;QACD,OAAO,OAAO,CAAC,GAAG,IAAI,CAAC,CAAC;IAC1B,CAAC,CAAiB,CAAC;IACnB,OAAO,OAAO,CAAC;AACjB,CAAC;AA0BD;;;;;;;;;;;;GAYG;AACH,SAAgB,SAAS,CACvB,MAA0B,EAC1B,IAAY,EACZ,WAAmB,EACnB,WAAiB;AACjB,8DAA8D;AAC9D,OAAgC;IAEhC,MAAM,CAAC,YAAY,CACjB,IAAI,EACJ;QACE,WAAW;QACX,WAAW;QACX,KAAK,EAAE,EAAE,CAAC,4BAAoB,CAAC,EAAE,IAAI,EAAE;KACxC,EACD,OAAO,CACR,CAAC;AACJ,CAAC;AAWD;;;;;;;;;GASG;AACH,SAAgB,cAAc,CAC5B,MAAkD,EAClD,IAAY,EACZ,KAAa,EACb,WAAmB,EACnB,WAAiB;AACjB,8DAA8D;AAC9D,OAAgC;IAEhC,IAAI,WAAW,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC;QAC7B,MAAM,CAAC,IAAI,CAAC,IAAI,EAAE,WAAW,EAAE,WAAW,EAAE,aAAa,CAAC,IAAI,EAAE,KAAK,EAAE,OAAO,CAAC,CAAC,CAAC;QACjF,OAAO;IACT,CAAC;IACD,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,WAAW,EAAE,WAAW,EAAE,OAAO,CAAC,CAAC;AAC7D,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;AA4DA,8BAiBC;AA7ED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,0DAA0D;AAC7C,QAAA,oBAAoB,GAAG,sBAA+B,CAAC;AA0BpE;;;;;;;;;;;;GAYG;AACH,SAAgB,SAAS,CACvB,MAA0B,EAC1B,IAAY,EACZ,WAAmB,EACnB,WAAiB;AACjB,8DAA8D;AAC9D,OAAgC;IAEhC,MAAM,CAAC,YAAY,CACjB,IAAI,EACJ;QACE,WAAW;QACX,WAAW;QACX,KAAK,EAAE,EAAE,CAAC,4BAAoB,CAAC,EAAE,IAAI,EAAE;KACxC,EACD,OAAO,CACR,CAAC;AACJ,CAAC"}
@@ -21,72 +21,6 @@
21
21
  /** Wire-format metadata key the Claude Code SDK reads. */
22
22
  export const ALWAYS_LOAD_META_KEY = "anthropic/alwaysLoad" as const;
23
23
 
24
- /**
25
- * Admin-seat detection. The rc-daemon exports `MAXY_SESSION_ROLE=admin` and
26
- * `MAXY_SPECIALIST=""` for the orchestrator process and its MCP plugin
27
- * children; specialist spawns set `MAXY_SPECIALIST=<name>`. A plugin process
28
- * inheriting the env can therefore decide at registration AND invocation
29
- * time whether it is loaded inside the admin seat.
30
- *
31
- * Mirrors `isAdminSeat` in platform/plugins/admin/mcp/src/skill-resolution.ts
32
- * — this is a duplicate at the library layer so plugins that cannot import
33
- * from the admin plugin (a sibling, not a dependency) share the same rule.
34
- */
35
- export function isAdminSeat(env: NodeJS.ProcessEnv = process.env): boolean {
36
- return env.MAXY_SESSION_ROLE === "admin" && !(env.MAXY_SPECIALIST && env.MAXY_SPECIALIST.length > 0);
37
- }
38
-
39
- /**
40
- * MCP refusal envelope returned when the admin seat invokes a specialist-
41
- * owned tool. The text names the dispatch route so the model's next action
42
- * is the `Agent` dispatch rather than a dead end.
43
- */
44
- export function adminBlockedResponse(toolName: string, owner: string): {
45
- content: { type: "text"; text: string }[];
46
- isError: true;
47
- } {
48
- return {
49
- content: [
50
- {
51
- type: "text",
52
- text:
53
- `Tool \`${toolName}\` is owned by the \`${owner}\` specialist and is not callable from the admin seat. ` +
54
- `Dispatch \`${owner}\` with the Agent tool and pass the work as the brief.`,
55
- },
56
- ],
57
- isError: true,
58
- };
59
- }
60
-
61
- /**
62
- * Wrap a tool handler so any admin-seat invocation short-circuits with the
63
- * refusal envelope above and emits one `[admin-tool-gate]` line to stderr.
64
- *
65
- * Specialist seats pass through unchanged: this is the primitive Task 529
66
- * defines for closing the in-message authoring path that Task 516 could
67
- * not reach. The handler signature is preserved so existing call sites
68
- * substitute `eagerTool(... handler)` for `eagerTool(... withAdminGate(name,
69
- * owner, handler))` mechanically.
70
- */
71
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
72
- export function withAdminGate<H extends (...args: any[]) => any>(
73
- toolName: string,
74
- owner: string,
75
- handler: H,
76
- ): H {
77
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
78
- const wrapped = ((...args: any[]) => {
79
- if (isAdminSeat(process.env)) {
80
- process.stderr.write(
81
- `[admin-tool-gate] role=admin tool=${toolName} decision=block reason=specialist-owned owner=${owner}\n`,
82
- );
83
- return adminBlockedResponse(toolName, owner);
84
- }
85
- return handler(...args);
86
- }) as unknown as H;
87
- return wrapped;
88
- }
89
-
90
24
  /**
91
25
  * Loose structural type for any object exposing a `registerTool` method. The
92
26
  * MCP SDK ships dual ESM/CJS packages with conditional exports, so importing
@@ -142,38 +76,3 @@ export function eagerTool<Args extends LocalZodRawShape>(
142
76
  handler,
143
77
  );
144
78
  }
145
-
146
- /**
147
- * Loose structural type for the legacy four-arg `server.tool(name, desc,
148
- * schema, handler)` call shape — used when the admin seat must register a
149
- * specialist-owned tool as deferred (not eager) so it does not appear in
150
- * the admin's eager tool list.
151
- */
152
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
153
- type DeferredCapableServer = { tool: (name: string, description: string, inputSchema: any, handler: (...args: any[]) => any) => any };
154
-
155
- /**
156
- * Register a tool that is eager for specialists but deferred + admin-gated
157
- * for the admin seat. The owning specialist name is named here so the
158
- * refusal envelope (and the `[admin-tool-gate]` log line) carries it.
159
- *
160
- * Use for every tool whose owner is a specialist (database-operator, writer-
161
- * craft, research-assistant, personal-assistant, content-producer). The
162
- * admin seat sees the tool only via ToolSearch — and any invocation from the
163
- * admin seat refuses with the dispatch hint.
164
- */
165
- export function eagerToolGated<Args extends LocalZodRawShape>(
166
- server: EagerCapableServer & DeferredCapableServer,
167
- name: string,
168
- owner: string,
169
- description: string,
170
- inputSchema: Args,
171
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
172
- handler: (...args: any[]) => any,
173
- ): void {
174
- if (isAdminSeat(process.env)) {
175
- server.tool(name, description, inputSchema, withAdminGate(name, owner, handler));
176
- return;
177
- }
178
- eagerTool(server, name, description, inputSchema, handler);
179
- }
@@ -106,7 +106,7 @@ Tools are available via the `admin` MCP server.
106
106
 
107
107
  **Admin-seat authoring-skill gate (Task 516).** `skill-load` and `plugin-read` refuse to return the body of a content-producer-owned authoring skill — `professional-document`, `a4-print-documents`, `publish-site` — when the caller is the admin seat (`MAXY_SESSION_ROLE=admin` with no `MAXY_SPECIALIST`). The refusal relays one line — "This deliverable is owned by content-producer. Dispatch it with the Agent tool and subagent_type content-producer." — so the admin's next action is the dispatch, not inline authoring. `plugin-read` extracts the skill slug from a `skills/<slug>/…` file path (or a slug buried in `pluginName`), so the raw-SKILL.md read path can't bypass the gate; `PLUGIN.md` and plain `references/*` reads are untouched. content-producer's own spawns carry `MAXY_SPECIALIST=content-producer` and resolve these skills normally (it reaches them via the native `Skill` tool regardless), and the public seat is out of scope. The gate is a pure name+env check before any filesystem read and fails open for any non-admin or unidentified seat — it never bricks a legitimate load. Each block emits `[admin-skill-gate] role=admin skill=<name> decision=block reason=content-producer-owned`. This is the enforcement layer; the IDENTITY.md delegation paragraph documents the intent but is not the gate ([[feedback_deterministic_means_remove_llm]], [[feedback_doctrine_paragraph_is_not_a_gate]]). Skills the admin invokes directly are deliberately excluded: `unzip-attachment` (its SKILL.md says "Invoked by the admin agent directly — admin owns all unzipping", then routes the extracted tree to a specialist) and `deck-pages` (admin-owned, a content-producer refusal target). The set is each skill's own SKILL.md "Invoked by" declaration, not the admin plugin's skill catalogue (which lists these admin-hosted skills for discovery — the catalogue is the inline path this gate closes).
108
108
 
109
- **Three-store admin auth invariant.** `admin-add` writes to all three identity stores (`users.json` PIN auth at the persistent `~/{configDir}/users.json` location , `account.json` `admins[]` role, Neo4j `:AdminUser`/`:Person` graph identity) with per-leg `[admin-auth-store]` log lines plus the `[admins-write]` chokepoint line, and returns `is_error: true` on any leg failure naming what's already written. `admin-update-pin` writes `users.json` only and emits the same `[admin-auth-store]` line. **Single chokepoint:** every `users.json` + `account.json admins[]` mutation routes through `platform/lib/admins-write` (`writeAdminEntry` for admin-add/set-pin, `removeAdminFromAccount` for admin-remove); the static check `grep -rnE 'admins\.push\|config\.admins\s*=' platform/ \| grep -v lib/admins-write` returns 0. Direct `Edit`/`Write` on `account.json` is blocked at the `pre-tool-use` hook — mutations go through `account-update`, `plugin-toggle-enabled`, or the `admin-*` tools. **Install + boot invariants:** the installer and admin-server boot walk every account.json admins[] vs users.json; `[install-invariant]` / `[admin-invariant] direction=… userId=<id8> source=<file>` fires per divergence with a `check complete divergences=<n>` summary. Log-only — does not block. See `.docs/agents.md` § "Three-store admin auth invariant" for the full contract.
109
+ **Three-store admin auth invariant.** `admin-add` writes to all three identity stores (`users.json` PIN auth at the persistent `~/{configDir}/users.json` location , `account.json` `admins[]` role, Neo4j `:AdminUser`/`:Person` graph identity) with per-leg `[admin-auth-store]` log lines plus the `[admins-write]` chokepoint line, and returns `is_error: true` on any leg failure naming what's already written. `admin-update-pin` writes `users.json` only and emits the same `[admin-auth-store]` line. **Single chokepoint:** every `users.json` + `account.json admins[]` mutation routes through `platform/lib/admins-write` (`writeAdminEntry` for admin-add/set-pin, `removeAdminFromAccount` for admin-remove); the static check `grep -rnE 'admins\.push\|config\.admins\s*=' platform/ \| grep -v lib/admins-write` returns 0. Direct `Edit`/`Write` on `account.json` is forbidden by IDENTITY.md doctrine — mutations go through `account-update`, `plugin-toggle-enabled`, or the `admin-*` tools. **Install + boot invariants:** the installer and admin-server boot walk every account.json admins[] vs users.json; `[install-invariant]` / `[admin-invariant] direction=… userId=<id8> source=<file>` fires per divergence with a `check complete divergences=<n>` summary. Log-only — does not block. See `.docs/agents.md` § "Three-store admin auth invariant" for the full contract.
110
110
 
111
111
  **AdminUser graph stamp + boot self-heal.** The seed cypher in `platform/scripts/seed-neo4j.sh` stamps `:AdminUser.accountId` + `:AdminUser.role='owner'` ON CREATE so the graph-write gate's account-scoped MATCH succeeds on the first user-domain write. Admin-server boot additionally runs `platform/ui/app/lib/adminuser-self-heal.ts` to repair legacy nodes whose `accountId` or `role` is null — emits `[adminuser-self-heal] healed=<N> userId=<8> accountId=<8>` once per boot, idempotent. The `graph-write-gate` reject log gains a `subReason=` field (`no-admin-user-node` / `admin-user-no-accountid` / `no-business-or-personal-profile`) for operator diagnosis. See `.docs/agents.md` § "AdminUser graph stamp contract" for the full contract.
112
112
 
@@ -139,12 +139,11 @@ Tools are available via the `admin` MCP server.
139
139
 
140
140
  ## Hooks
141
141
 
142
- - `hooks/pre-tool-use.sh` — enforces admin agent write boundaries
143
142
  - `hooks/webfetch-preflight.mjs` — short-circuits WebFetch on JS-SPA shells with a structured `WEBFETCH_CANNOT_READ_JS_SPA` error so the agent surfaces a loud failure to the owner instead of paying the 60s extraction timeout. Fail-open on any internal error.
144
143
  - `hooks/askuserquestion-investigate-gate.sh` — PreToolUse matcher=`AskUserQuestion`. Blocks the question (exit 2) when no read-only investigation tool has fired since the latest real user turn in the session JSONL. The structural fix for the failure class where the agent fabricates a menu before evidence-gathering (session `c085ec2c-46fb-4b73-8865-68cf85866ea8` 2026-05-22 — "change remote access password" → invented options "Admin PIN / Cloudflare tunnel / WiFi password" with zero prior tool_use; post-correction the agent immediately fired `remote-auth-status` → `ToolSearch` → `remote-auth-set-password`, proving it knew the moves). **Allowlist** (exact, with trailing `__<tool>` suffix-match for namespaced `mcp__plugin_<plugin>_<server>__<tool>` aliases): `ToolSearch`, `Grep`, `Glob`, `Read`, `LS`, `NotebookRead`, `Bash`, `WebFetch`, `WebSearch`, plus the read-only admin / memory MCP tools (`*-status`, `*-list`, `*-read`, `skill-find`, `memory-find-candidates`, `profile-read`, `conversation-list`, `memory-list-attachments`, `memory-read-attachment`). **Block message:** `Blocked: AskUserQuestion requires at least one investigation tool (ToolSearch, Grep, Read, *-list, *-status, *-read, skill-find, ...) earlier in this turn. Search the operator's literal phrase first.` **Log line** (stderr, one per call): `[ask-gate] decision=<allow|block> sessionId=<id8> seen=<csv|-> reason=<allowlist-hit|no-investigation|fail-open-no-transcript|fail-open-parse-error>`. **Fail-open** on missing transcript or parse error — nudges, never bricks the UI.
145
144
  - `hooks/session-end-retrospective.sh` — **Stop hook.** Fires on every assistant end_turn but exits 0 with `trigger-skipped reason=<role-not-admin|is-specialist|empty-stdin|missing-transcript|no-intent-match>` on every path EXCEPT when the operator's latest real-user message carries an end-intent token (`/end`, `/archive`, `end session`, `archive this session`, case-insensitive, must match the whole message or a standalone line — not embedded in prose). On that path the hook blocks (exit 2 + structured retrospective instruction on stderr per Stop-hook contract) and emits `[session-retrospective] gate-blocked sessionId=<id> reason=sentinel-absent` until the admin agent calls the `session-retrospective-mark-complete` MCP sentinel tool. Completion is recognised by greping the operator's own JSONL (`transcript_path` on the Stop envelope) for a `tool_use` block whose `name` equals that exact string — never by parsing prose ([[feedback_no_stdout_parsing_for_control_flow]], [[feedback_doctrine_paragraph_is_not_a_gate]]). The sentinel-grep doubles as the re-entry guard: every Stop after the sentinel call sees it and exits 0 with `gate-released sessionId=<id>`. Recursion guard: any session whose `MAXY_SPECIALIST` env is set (specialist subagent dispatched via Task tool) skips the gate. The Sidebar archive button POSTs `/:sessionId/archive` directly to the manager which kills the PTY — no Stop fires after that, so clicking archive is the operator's current escape hatch (opt-out remains deferred per the task spec). All emits go through `POST /api/admin/log-ingest`; the only stderr writer is the gate-blocked path (the instruction block the agent reads). The retrospective itself runs as one or more additional turns inside the operator's existing admin session — Task-tool delegation to `database-operator` is the existing IDENTITY.md "Recording" route, not a parallel admin session ([[feedback_deterministic_means_remove_llm]]).
146
145
  - `hooks/mcp-tool-missing.sh` — **PostToolUse hook on `mcp__.*` (Task 502, directive 3).** Defence-in-depth for the `No such tool available: mcp__…` failure class that the Task 502 name-binding is built to eliminate. Fires on any MCP tool call; no-op unless the `tool_response` carries `No such tool available` AND the qualified name resolves to a maxy plugin (read from the generated `hooks/lib/maxy-mcp-plugins.txt`). On a maxy match it logs one deterministic `[mcp-tool-missing] server=<server> tool=<tool>` line and exits 2 with a fixed envelope on stderr, so the agent relays a named server-unavailable failure instead of narrating "warming up" or blind-retrying. A missing non-maxy bridge tool (Playwright etc., upstream-owned) passes through (exit 0). The maxy-plugin list is regenerated and gate-diffed by `platform/scripts/check-canonical-tool-names.mjs`.
147
- - `hooks/post-tool-use-agent.sh` — **PostToolUse hook on `Agent` (Task 560).** Drains any subagent hook-decision buffers under `~/.maxy-code/logs/hook-decisions/` modified since this parent's previous PostToolUse-Agent fire (cursor file keyed by parent session id), prints one `[hook-propagate]` line per record to stdout — Claude Code attaches the stdout as a `hook_success` attachment on the parent JSONL, making the records grep-queryable from the parent session alone (Task 559 motivating case). Rotates consumed buffers to `consumed/`. Emits one `[hook-propagate-census] parentSession=<…> subagentHooksObserved=<N> attachmentsEmitted=<M>` line per fire to stdout and server.log; `N != M` is the propagation regression signal. The companion emitter library `hooks/lib/hook-emit.sh` is sourced by `pre-tool-use.sh` and writes one record per block decision (4 KB stderr truncation, `truncated=true` set on the record).
146
+ - `hooks/post-tool-use-agent.sh` — **PostToolUse hook on `Agent` (Task 560).** Drains any subagent hook-decision buffers under `~/.maxy-code/logs/hook-decisions/` modified since this parent's previous PostToolUse-Agent fire (cursor file keyed by parent session id), prints one `[hook-propagate]` line per record to stdout — Claude Code attaches the stdout as a `hook_success` attachment on the parent JSONL, making the records grep-queryable from the parent session alone (Task 559 motivating case). Rotates consumed buffers to `consumed/`. Emits one `[hook-propagate-census] parentSession=<…> subagentHooksObserved=<N> attachmentsEmitted=<M>` line per fire to stdout and server.log; `N != M` is the propagation regression signal. The companion emitter library `hooks/lib/hook-emit.sh` is sourced by `post-tool-use-agent.sh` and any other hook that records a block decision (4 KB stderr truncation, `truncated=true` set on the record).
148
147
  - `hooks/admin-authoring-observer.sh` — **PostToolUse hook on Write and Edit (Task 486).** Observation only — never blocks; exits 0 on every path. Fires when the admin agent (not a specialist subagent — gated by `MAXY_SPECIALIST` env) writes or edits a file under `<accountDir>/output/`. Walks the session transcript from the latest real-user turn forward to detect any prior `Task` `tool_use` whose `subagent_type` starts with `specialists:`. Emits one stderr line `[admin-authoring] inline-write path=<rel> priorSpecialistSpawnInTurn=<true|false|unknown>`. A `false` value on a long-form prose file is the regression signal Task 486 was designed to make visible — the BioSymm proposal session (admin authored a customer-facing proposal inline despite content-producer being installed) is the failure mode this surfaces mechanically. Mechanical enforcement (refuse the write, force a re-spawn) is deferred per the task spec.
149
148
  - `hooks/turn-completed-graph-write.sh` — **Dormant since Task 214** — the Stop-hook registration is no longer written to account settings.json; admin delegates graph writes to `database-operator` via the Task tool. The script, envelope walker, loopback `/api/admin/claude-sessions` 127.0.0.1 bypass, `[turn-recorder]` emitters, recorder-auto-archive subscriber, and `initialMessage` envelope spec are preserved as reusable infrastructure for any future autonomous post-turn flow. Historical contract (still describes the dormant path): Stop hook fired once per completed admin-agent turn. Gates on `MAXY_SESSION_ROLE=admin` + `MAXY_SPECIALIST!=database-operator` so it never recurses into the recorder PTY or fires on public sessions. **Task 147** — the recorder is spawned via the same route Sidebar uses. ONE POST to `POST /api/admin/claude-sessions`, body carries `{specialist:'database-operator', model:'haiku', initialMessage:<json-envelope>, adminSessionId:<op>}` — no synthetic `senderId: 'turn-recorder'` marker. The Hono wrapper bypasses cookie auth on this exact method+path when the request originates from `127.0.0.1` (same trust boundary the claude-session-manager itself relies on), looks up the operator's senderId from the manager's `/<adminSessionId>/meta`, and forwards a Sidebar-shape spawn body. The `/recorder-spawn` sibling route is gone. The hook reads its UI port from `MAXY_UI_INTERNAL_PORT` (stamped on the manager systemd unit) — no fallback; absence emits `[turn-recorder] spawn-failed reason=missing-env env=MAXY_UI_INTERNAL_PORT` to stderr instead of silently 19199-ing. **Task 177** — `initialMessage` is a JSON-stringified envelope; the schema lives in [`platform/plugins/docs/references/admin-session.md`](../docs/references/admin-session.md) under "`initialMessage` JSON envelope (Task 177)". Top-level keys exactly: `turns`, `sessionId`, `accountId`, `occurredAt`. `turns` is the full conversation transcript, oldest first, newest last, with each entry `{ role: "user"|"assistant", text, ts, toolCalls? }`. No windowing, no truncation. Multi-record assistant messages collapse on `message.id`. `tool_use` and matching `tool_result` blocks attach as a single `toolCalls` entry on the owning assistant turn; the user record carrying only the `tool_result` does not create a separate user turn. No leading instruction prose. `toolCalls[].input` and `toolCalls[].output` are native JSON values, never re-stringified. (Replaces Task 175's `(operatorMessage, assistantReply)` pair, which asserted a temporal Q→A relationship the walker never enforced.) One observability line `[turn-recorder] envelope sessionId=<op> turnsCount=<n> userTurns=<n> assistantTurns=<n> toolCallTurns=<n>` precedes `spawn-request`. The envelope rides on the `/spawn` body as a trailing positional argv to `claude`, so the database-operator session's JSONL first `role=user` line is the JSON object verbatim. No separate `POST /:id/input` call, no bracketed-paste, no keystroke injection. The recorder-auto-archive subscriber stops the recorder PTY as soon as its JSONL contains an assistant message with `stop_reason === "end_turn"`. **Task 129** — every emit goes through `POST /api/admin/log-ingest` so the lines land in `server.log` keyed by the operator session id. The chain is `trigger` → `spawn-request` → `spawn-success` → `input-delivered` → `tool-call` × N → `tool-result` × N → `write-complete` → `auto-archive`; each gated-off path emits one `trigger-skipped reason=<role-not-admin|is-recorder|empty-stdin|missing-transcript|conversation-empty>` line. Failure modes — `spawn-failed`, `tool-surface-missing`, `input-failed`, `write-empty`, `auto-archive reason=stale-recorder` — each emit one named line; absence is itself a defect.
150
149
 
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env bash
2
+ # Task 569 — stale-lockdir TTL reclamation in _hook_acquire_lock and
3
+ # _drain_acquire_lock. A backdated lockdir is force-removed on acquire;
4
+ # a freshly held lockdir is not.
5
+ set -u
6
+
7
+ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
8
+ LIB="$ROOT/lib/hook-emit.sh"
9
+ DRAIN="$ROOT/post-tool-use-agent.sh"
10
+ [[ -f "$LIB" ]] || { echo "FAIL: $LIB missing"; exit 1; }
11
+ [[ -f "$DRAIN" ]] || { echo "FAIL: $DRAIN missing"; exit 1; }
12
+
13
+ TMP=$(mktemp -d)
14
+ export MAXY_HOOK_BUFFER_DIR="$TMP/buffers"
15
+ export MAXY_HOOK_SERVER_LOG="$TMP/server.log"
16
+ # Tight test budget — 20 × 10ms = 200ms ceiling so the negative case
17
+ # returns quickly.
18
+ export MAXY_HOOK_LOCK_ATTEMPTS=20
19
+ export MAXY_HOOK_LOCK_SLEEP=0.01
20
+ export MAXY_HOOK_LOCK_TTL_SECONDS=2
21
+ mkdir -p "$MAXY_HOOK_BUFFER_DIR"
22
+
23
+ PASS=0; FAIL=0
24
+ pass() { PASS=$((PASS+1)); echo "PASS: $1"; }
25
+ fail() { FAIL=$((FAIL+1)); echo "FAIL: $1" >&2; }
26
+
27
+ # shellcheck disable=SC1090
28
+ source "$LIB" || { fail "source library"; exit 1; }
29
+ pass "source library"
30
+
31
+ # Test 1: a stale lockdir (mtime backdated 10s; TTL=2s) is reclaimed and
32
+ # acquire succeeds.
33
+ LOCK1="$TMP/lock-stale"
34
+ mkdir "$LOCK1"
35
+ # Backdate mtime by 10s. -t needs YYYYMMDDhhmm.SS form; portable form
36
+ # uses -d on GNU and -A/-r on BSD, so we use python to set it.
37
+ python3 -c "import os,time; os.utime('$LOCK1', (time.time()-10, time.time()-10))"
38
+ if _hook_acquire_lock "$LOCK1"; then
39
+ pass "stale lockdir reclaimed (emit-path)"
40
+ else
41
+ fail "stale lockdir not reclaimed within budget (emit-path)"
42
+ fi
43
+ # Verify observability line.
44
+ if grep -q "\[hook-propagate-error\] reason=stale-lock-reclaimed lockdir=$LOCK1" "$MAXY_HOOK_SERVER_LOG"; then
45
+ pass "stale-lock-reclaimed observability line emitted"
46
+ else
47
+ fail "stale-lock-reclaimed line missing from server.log"
48
+ fi
49
+ rmdir "$LOCK1" 2>/dev/null || true
50
+
51
+ # Test 2: a fresh lockdir (mtime = now) is NOT reclaimed and acquire
52
+ # returns 1 within the attempt budget.
53
+ LOCK2="$TMP/lock-fresh"
54
+ mkdir "$LOCK2"
55
+ START=$(date +%s)
56
+ if _hook_acquire_lock "$LOCK2"; then
57
+ fail "fresh lockdir wrongly reclaimed (emit-path)"
58
+ else
59
+ END=$(date +%s)
60
+ pass "fresh lockdir not reclaimed; acquire timed out (emit-path, ${END}-${START}s)"
61
+ fi
62
+ # Verify no stale-lock-reclaimed line was written for LOCK2.
63
+ if grep -q "lockdir=$LOCK2 ageSeconds=" "$MAXY_HOOK_SERVER_LOG"; then
64
+ fail "fresh lockdir wrongly logged as stale (emit-path)"
65
+ else
66
+ pass "no stale-lock log for fresh lockdir (emit-path)"
67
+ fi
68
+ rmdir "$LOCK2" 2>/dev/null || true
69
+
70
+ # Test 3: same property for the drainer-side function. Extract
71
+ # _drain_acquire_lock + its age helper from post-tool-use-agent.sh and
72
+ # source them in isolation.
73
+ DRAIN_LIB="$TMP/drain-lock.sh"
74
+ awk '
75
+ /^_drain_lockdir_age_seconds\(\)/,/^}/ { print; next }
76
+ /^_drain_acquire_lock\(\)/,/^}/ { print; next }
77
+ ' "$DRAIN" > "$DRAIN_LIB"
78
+ [[ -s "$DRAIN_LIB" ]] || { fail "drain lock extract empty"; }
79
+ # shellcheck disable=SC1090
80
+ source "$DRAIN_LIB" || fail "source drain lib"
81
+
82
+ LOCK3="$TMP/lock-drain-stale"
83
+ mkdir "$LOCK3"
84
+ python3 -c "import os,time; os.utime('$LOCK3', (time.time()-10, time.time()-10))"
85
+ if _drain_acquire_lock "$LOCK3"; then
86
+ pass "stale lockdir reclaimed (drain-path)"
87
+ else
88
+ fail "stale lockdir not reclaimed (drain-path)"
89
+ fi
90
+
91
+ LOCK4="$TMP/lock-drain-fresh"
92
+ mkdir "$LOCK4"
93
+ if _drain_acquire_lock "$LOCK4"; then
94
+ fail "fresh lockdir wrongly reclaimed (drain-path)"
95
+ else
96
+ pass "fresh lockdir not reclaimed; acquire timed out (drain-path)"
97
+ fi
98
+
99
+ rm -rf "$TMP"
100
+ echo "----"
101
+ echo "$PASS pass, $FAIL fail"
102
+ [[ "$FAIL" -eq 0 ]]
@@ -52,8 +52,7 @@
52
52
  # server.log alongside the [whatsapp-ingest] script lines.
53
53
  #
54
54
  # Exit codes follow Claude Code hook protocol: 0 = allow, 2 = block (stderr
55
- # message shown to the agent). Fail-closed on terminal stdin to match
56
- # pre-tool-use.sh.
55
+ # message shown to the agent). Fail-closed on terminal stdin.
57
56
 
58
57
  set -uo pipefail
59
58
 
@@ -14,11 +14,39 @@
14
14
  # (tests) without flock(1).
15
15
  : "${MAXY_HOOK_LOCK_ATTEMPTS:=100}"
16
16
  : "${MAXY_HOOK_LOCK_SLEEP:=0.01}"
17
+ # Task 569 — TTL for orphaned lockdirs left behind when a holder dies
18
+ # between `mkdir lock` and `rmdir lock` (SIGKILL, OOM, systemd restart).
19
+ # Default 5s is far longer than the legitimate hold (microseconds for a
20
+ # rename or append) and shorter than operator-noticeable propagation lag.
21
+ : "${MAXY_HOOK_LOCK_TTL_SECONDS:=5}"
22
+
23
+ # _hook_lockdir_age_seconds <lockdir> — prints integer age in seconds, or
24
+ # -1 if the lockdir cannot be stat'd. Portable across Linux (`stat -c`)
25
+ # and macOS (`stat -f`).
26
+ _hook_lockdir_age_seconds() {
27
+ local lockdir="$1" mtime now
28
+ mtime=$(stat -c %Y "$lockdir" 2>/dev/null || stat -f %m "$lockdir" 2>/dev/null)
29
+ [[ -z "$mtime" ]] && { echo -1; return; }
30
+ now=$(date +%s)
31
+ echo $((now - mtime))
32
+ }
17
33
 
18
34
  # _hook_acquire_lock <lockdir> — returns 0 on success, 1 on timeout.
35
+ # On each mkdir failure, if the lockdir is older than
36
+ # MAXY_HOOK_LOCK_TTL_SECONDS, the orphaned holder is presumed dead: the
37
+ # lockdir is force-removed, a stale-lock-reclaimed observability line is
38
+ # emitted, and one mkdir retry is attempted before resuming the spin.
19
39
  _hook_acquire_lock() {
20
- local lockdir="$1" i=0
40
+ local lockdir="$1" i=0 age
21
41
  while ! mkdir "$lockdir" 2>/dev/null; do
42
+ age=$(_hook_lockdir_age_seconds "$lockdir")
43
+ if [[ "$age" -ge "$MAXY_HOOK_LOCK_TTL_SECONDS" ]]; then
44
+ rmdir "$lockdir" 2>/dev/null || rm -rf "$lockdir" 2>/dev/null
45
+ mkdir -p "$(dirname "$MAXY_HOOK_SERVER_LOG")" 2>/dev/null
46
+ printf '[hook-propagate-error] reason=stale-lock-reclaimed lockdir=%s ageSeconds=%s\n' \
47
+ "$lockdir" "$age" >>"$MAXY_HOOK_SERVER_LOG" 2>/dev/null || true
48
+ mkdir "$lockdir" 2>/dev/null && return 0
49
+ fi
22
50
  i=$((i+1))
23
51
  [[ "$i" -ge "$MAXY_HOOK_LOCK_ATTEMPTS" ]] && return 1
24
52
  sleep "$MAXY_HOOK_LOCK_SLEEP" 2>/dev/null || true
@@ -102,8 +130,9 @@ PY
102
130
 
103
131
  # hook_block_with_emit <agentId> <hookName> <toolName> <reason>
104
132
  # <userMessage> [durationMs]
105
- # Convenience wrapper for pre-tool-use.sh: emits the propagation record,
106
- # echoes the user-facing block message to stderr, and exits 2.
133
+ # Convenience wrapper for any PreToolUse hook that records a block decision:
134
+ # emits the propagation record, echoes the user-facing block message to stderr,
135
+ # and exits 2.
107
136
  hook_block_with_emit() {
108
137
  local agentId="$1" hookName="$2" toolName="$3" reason="$4"
109
138
  local userMessage="$5" durationMs="${6:-0}"
@@ -28,6 +28,8 @@ set -uo pipefail
28
28
  : "${MAXY_HOOK_SERVER_LOG:=$HOME/.maxy-code/logs/server.log}"
29
29
  : "${MAXY_HOOK_LOCK_ATTEMPTS:=100}"
30
30
  : "${MAXY_HOOK_LOCK_SLEEP:=0.01}"
31
+ # Task 569 — TTL for orphaned lockdirs (see hook-emit.sh for rationale).
32
+ : "${MAXY_HOOK_LOCK_TTL_SECONDS:=5}"
31
33
 
32
34
  if [ -t 0 ]; then
33
35
  # No stdin — no correlation possible. Fail-open.
@@ -65,9 +67,26 @@ NOW_EPOCH=$(date +%s)
65
67
  N_OBSERVED=0
66
68
  N_EMITTED=0
67
69
 
70
+ # Task 569 — same stale-lockdir TTL reclamation as hook-emit.sh.
71
+ _drain_lockdir_age_seconds() {
72
+ local lockdir="$1" mtime now
73
+ mtime=$(stat -c %Y "$lockdir" 2>/dev/null || stat -f %m "$lockdir" 2>/dev/null)
74
+ [[ -z "$mtime" ]] && { echo -1; return; }
75
+ now=$(date +%s)
76
+ echo $((now - mtime))
77
+ }
78
+
68
79
  _drain_acquire_lock() {
69
- local lockdir="$1" i=0
80
+ local lockdir="$1" i=0 age
70
81
  while ! mkdir "$lockdir" 2>/dev/null; do
82
+ age=$(_drain_lockdir_age_seconds "$lockdir")
83
+ if [[ "$age" -ge "$MAXY_HOOK_LOCK_TTL_SECONDS" ]]; then
84
+ rmdir "$lockdir" 2>/dev/null || rm -rf "$lockdir" 2>/dev/null
85
+ mkdir -p "$(dirname "$MAXY_HOOK_SERVER_LOG")" 2>/dev/null
86
+ printf '[hook-propagate-error] reason=stale-lock-reclaimed lockdir=%s ageSeconds=%s\n' \
87
+ "$lockdir" "$age" >>"$MAXY_HOOK_SERVER_LOG" 2>/dev/null || true
88
+ mkdir "$lockdir" 2>/dev/null && return 0
89
+ fi
71
90
  i=$((i+1))
72
91
  [[ "$i" -ge "$MAXY_HOOK_LOCK_ATTEMPTS" ]] && return 1
73
92
  sleep "$MAXY_HOOK_LOCK_SLEEP" 2>/dev/null || true
@@ -755,8 +755,8 @@ eagerTool(server, "account-update", "Update a user-configurable setting in accou
755
755
  }
756
756
  });
757
757
  // Plugin enable/disable: deterministic write to account.json.enabledPlugins.
758
- // The pre-tool-use hook denies the agent's direct Edit on account.json,
759
- // so the agent can no longer toggle enablement by hand-editing the file. This
758
+ // IDENTITY.md doctrine forbids the agent's direct Edit on account.json,
759
+ // so the agent must not toggle enablement by hand-editing the file. This
760
760
  // tool is the legitimate path: validates the plugin name, refuses core plugins,
761
761
  // confirms the plugin directory exists, and atomically updates the array.
762
762
  // Entitlement-bearing fields (tier, purchasedPlugins) are NOT writable here.