@kmmao/happy-agent 0.7.1 → 0.7.2

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.
package/dist/index.cjs CHANGED
@@ -39,7 +39,7 @@ function _interopNamespaceDefault(e) {
39
39
 
40
40
  var z__namespace = /*#__PURE__*/_interopNamespaceDefault(z);
41
41
 
42
- var version = "0.7.1";
42
+ var version = "0.7.2";
43
43
 
44
44
  function loadConfig() {
45
45
  const serverUrl = (process.env.HAPPY_SERVER_URL ?? "https://s.sangreal.code.xycloud.info:2443").replace(/\/+$/, "");
@@ -157,6 +157,21 @@ function decrypt(key, variant, data) {
157
157
  return decryptWithDataKey(data, key);
158
158
  }
159
159
  }
160
+ function createCipher(key, variant) {
161
+ return {
162
+ encrypt(data) {
163
+ return encodeBase64(encrypt(key, variant, data));
164
+ },
165
+ decrypt(data) {
166
+ try {
167
+ const value = decrypt(key, variant, decodeBase64(data));
168
+ return value === null ? { ok: false } : { ok: true, value };
169
+ } catch {
170
+ return { ok: false };
171
+ }
172
+ }
173
+ };
174
+ }
160
175
  function libsodiumEncryptForPublicKey(data, recipientPublicKey) {
161
176
  const ephemeralKeyPair = tweetnacl.box.keyPair();
162
177
  const nonce = getRandomBytes(tweetnacl.box.nonceLength);
@@ -639,16 +654,14 @@ async function withBackoff(fn, options) {
639
654
  class RpcHandlerManager {
640
655
  handlers = /* @__PURE__ */ new Map();
641
656
  scopePrefix;
642
- encryptionKey;
643
- encryptionVariant;
657
+ cipher;
644
658
  logger;
645
659
  socket = null;
646
660
  reregisterInterval = null;
647
661
  fastRetryTimer = null;
648
662
  constructor(config) {
649
663
  this.scopePrefix = config.scopePrefix;
650
- this.encryptionKey = config.encryptionKey;
651
- this.encryptionVariant = config.encryptionVariant;
664
+ this.cipher = config.cipher;
652
665
  this.logger = config.logger ?? ((msg, data) => logger.debug(msg, data));
653
666
  }
654
667
  /**
@@ -662,45 +675,45 @@ class RpcHandlerManager {
662
675
  }
663
676
  }
664
677
  /**
665
- * Handle an incoming RPC request
678
+ * Route a decrypted RPC call to its handler. This is the plaintext core of
679
+ * the manager: it knows nothing about the wire (no base64, no cipher), so it
680
+ * is TOTAL — an unknown method or a throwing handler both resolve to an
681
+ * `{ error }` value rather than rejecting. That makes it the test surface for
682
+ * routing behaviour, exercised without any crypto setup.
666
683
  */
667
- async handleRequest(request) {
684
+ async dispatch(method, params) {
685
+ const handler = this.handlers.get(method);
686
+ if (!handler) {
687
+ this.logger("[RPC] [ERROR] Method not found", { method });
688
+ return { error: "Method not found" };
689
+ }
668
690
  try {
669
- const handler = this.handlers.get(request.method);
670
- if (!handler) {
671
- this.logger("[RPC] [ERROR] Method not found", {
672
- method: request.method
673
- });
674
- const errorResponse = { error: "Method not found" };
675
- return encodeBase64(
676
- encrypt(this.encryptionKey, this.encryptionVariant, errorResponse)
677
- );
678
- }
679
- const decryptedParams = decrypt(
680
- this.encryptionKey,
681
- this.encryptionVariant,
682
- decodeBase64(request.params)
683
- );
684
- this.logger("[RPC] Calling handler", { method: request.method });
685
- const result = await handler(decryptedParams);
691
+ this.logger("[RPC] Calling handler", { method });
692
+ const result = await handler(params);
686
693
  this.logger("[RPC] Handler returned", {
687
- method: request.method,
694
+ method,
688
695
  hasResult: result !== void 0
689
696
  });
690
- const encryptedResponse = encodeBase64(
691
- encrypt(this.encryptionKey, this.encryptionVariant, result)
692
- );
693
- return encryptedResponse;
697
+ return result;
694
698
  } catch (error) {
695
699
  this.logger("[RPC] [ERROR] Error handling request", { error });
696
- const errorResponse = {
697
- error: error instanceof Error ? error.message : "Unknown error"
698
- };
699
- return encodeBase64(
700
- encrypt(this.encryptionKey, this.encryptionVariant, errorResponse)
701
- );
700
+ return { error: error instanceof Error ? error.message : "Unknown error" };
702
701
  }
703
702
  }
703
+ /**
704
+ * Handle an incoming wire RPC request: decrypt params, dispatch in plaintext,
705
+ * encrypt the result. The Cipher is the only encryption seam; on a decrypt
706
+ * failure the handler is still dispatched with `null` params (preserving the
707
+ * previous behaviour where a corrupt payload decrypted to `null`).
708
+ */
709
+ async handleRequest(request) {
710
+ const decrypted = this.cipher.decrypt(request.params);
711
+ const result = await this.dispatch(
712
+ request.method,
713
+ decrypted.ok ? decrypted.value : null
714
+ );
715
+ return this.cipher.encrypt(result);
716
+ }
704
717
  onSocketConnect(socket) {
705
718
  this.socket = socket;
706
719
  this.registerAllHandlers(socket);
@@ -1464,8 +1477,7 @@ class SessionClient extends node_events.EventEmitter {
1464
1477
  });
1465
1478
  this.rpcHandlerManager = createRpcHandlerManager({
1466
1479
  scopePrefix: `session:${opts.sessionId}`,
1467
- encryptionKey: opts.encryptionKey,
1468
- encryptionVariant: opts.encryptionVariant,
1480
+ cipher: createCipher(opts.encryptionKey, opts.encryptionVariant),
1469
1481
  logger: (msg, data) => logger.debug(msg, data)
1470
1482
  });
1471
1483
  if (opts.enableRpc !== false) {
@@ -2673,6 +2685,17 @@ const sessionEnvelopeSchema = z__namespace.object({
2673
2685
  subagent: z__namespace.string().refine((value) => cuid2Exports.isCuid(value), {
2674
2686
  message: "subagent must be a cuid2 value"
2675
2687
  }).optional(),
2688
+ /**
2689
+ * Optional Claude-side message UUID for this envelope. Populated by the
2690
+ * CLI when an envelope mirrors a specific JSONL record, so the App can
2691
+ * use it as a precise rewind/fork anchor (the CLI's `forkSession` RPC
2692
+ * accepts this value as `upToMessageId`).
2693
+ *
2694
+ * Backward-compatible: older CLIs / non-Claude agents simply omit it.
2695
+ * App code MUST treat absence as "no fork anchor available" rather than
2696
+ * an error.
2697
+ */
2698
+ claudeUuid: z__namespace.string().min(1).optional(),
2676
2699
  ev: sessionEventSchema
2677
2700
  }).superRefine((envelope, ctx) => {
2678
2701
  if (envelope.ev.t === "service" && envelope.role !== "agent") {
@@ -3850,8 +3873,27 @@ const HAPPY_MCP_TOOL_NAMES = [
3850
3873
  "change_title",
3851
3874
  "query_project_knowledge",
3852
3875
  "update_progress",
3853
- "update_session_summary"
3876
+ "update_session_summary",
3877
+ "ask_user"
3854
3878
  ];
3879
+ const parseIfJsonString = (v) => {
3880
+ if (typeof v !== "string") return v;
3881
+ try {
3882
+ return JSON.parse(v);
3883
+ } catch {
3884
+ return v;
3885
+ }
3886
+ };
3887
+ const looseStringArray = () => z.z.union([z.z.array(z.z.string()), z.z.string()]).transform((v) => {
3888
+ if (Array.isArray(v)) return v;
3889
+ const parsed = parseIfJsonString(v);
3890
+ if (Array.isArray(parsed)) return parsed.map(String);
3891
+ return v.length ? [v] : [];
3892
+ });
3893
+ const looseObjectArray = (item) => z.z.union([
3894
+ z.z.array(item),
3895
+ z.z.string().transform(parseIfJsonString).pipe(z.z.array(item))
3896
+ ]);
3855
3897
  const HAPPY_MCP_TOOL_SPECS = {
3856
3898
  change_title: {
3857
3899
  title: "Change Title",
@@ -3886,7 +3928,7 @@ const HAPPY_MCP_TOOL_SPECS = {
3886
3928
  description: 'Optional override for the App\'s Progress tab. In most cases your TodoWrite calls are auto-mirrored, so you do NOT need to call this. Use it only when you want to set extra fields the CLI hook does not capture (currentStage, blockers) or to force a new list boundary with `listId: "new"`.',
3887
3929
  failureLabel: "Failed to update progress",
3888
3930
  inputSchema: {
3889
- todos: z.z.array(
3931
+ todos: looseObjectArray(
3890
3932
  z.z.object({
3891
3933
  content: z.z.string().describe("Concise description of the task"),
3892
3934
  status: z.z.enum(["pending", "in_progress", "completed"]).describe("Current status of the task"),
@@ -3897,7 +3939,7 @@ const HAPPY_MCP_TOOL_SPECS = {
3897
3939
  })
3898
3940
  ).describe("The full checklist \u2014 always send every item, not a delta"),
3899
3941
  currentStage: z.z.string().optional().describe("Optional overall stage name for the checklist"),
3900
- blockers: z.z.array(z.z.string()).optional().describe("Optional list of things blocking progress"),
3942
+ blockers: looseStringArray().optional().describe("Optional list of things blocking progress"),
3901
3943
  listId: z.z.string().optional().describe("Target list id. Use 'new' to force a fresh list"),
3902
3944
  label: z.z.string().optional().describe("Short human-readable name for this task list")
3903
3945
  },
@@ -3908,6 +3950,33 @@ const HAPPY_MCP_TOOL_SPECS = {
3908
3950
  fallbackAction: "Update progress",
3909
3951
  reasonPhrases: ["progress update", "progress updates", "update_progress"]
3910
3952
  },
3953
+ ask_user: {
3954
+ title: "Ask User",
3955
+ description: "Ask the user one or more questions via the App's native picker UI. Use this whenever AskUserQuestion is unavailable (the host has disabled it \u2014 common when running under happy-cli's PTY-mode Claude TUI). The input schema is identical to AskUserQuestion's. The tool blocks until the user submits answers in the App, then returns them as a JSON string keyed by question text.",
3956
+ failureLabel: "Failed to get user answer",
3957
+ inputSchema: {
3958
+ questions: looseObjectArray(
3959
+ z.z.object({
3960
+ question: z.z.string().describe("The question to ask the user"),
3961
+ header: z.z.string().describe("Short label/chip for the question (max ~12 chars)"),
3962
+ options: looseObjectArray(
3963
+ z.z.object({
3964
+ label: z.z.string().describe("Option label"),
3965
+ description: z.z.string().describe("Option description"),
3966
+ preview: z.z.string().optional().describe("Optional preview content shown when the option is focused")
3967
+ })
3968
+ ).describe("Available choices for this question (2-4 options)"),
3969
+ multiSelect: z.z.boolean().describe("Allow multiple selections instead of single-select")
3970
+ })
3971
+ ).describe("Questions to ask the user (1-4 questions)")
3972
+ },
3973
+ hideSuccessfulCall: false,
3974
+ autoApproveByDefault: false,
3975
+ permissionAction: "Waiting for user to answer",
3976
+ dynamicAction: "Waiting for user to answer",
3977
+ fallbackAction: "Ask user",
3978
+ reasonPhrases: ["ask user", "user input", "ask_user"]
3979
+ },
3911
3980
  update_session_summary: {
3912
3981
  title: "Update Session Summary",
3913
3982
  description: "Write a narrative session summary the App shows above the progress checklist. Call at milestones, not per task: after first understanding the goal, when scope shifts significantly, when key decisions are made, or when moving to a new phase. Full rewrite each call.",
@@ -3915,9 +3984,9 @@ const HAPPY_MCP_TOOL_SPECS = {
3915
3984
  inputSchema: {
3916
3985
  goal: z.z.string().describe("What the user ultimately wants to accomplish"),
3917
3986
  currentFocus: z.z.string().optional().describe("Brief description of the active task or phase"),
3918
- keyDecisions: z.z.array(z.z.string()).optional().describe("Important choices already made this session"),
3919
- openQuestions: z.z.array(z.z.string()).optional().describe("Unresolved questions or pending decisions"),
3920
- impactScope: z.z.array(z.z.string()).optional().describe("Modules/files/areas affected by this session's work"),
3987
+ keyDecisions: looseStringArray().optional().describe("Important choices already made this session"),
3988
+ openQuestions: looseStringArray().optional().describe("Unresolved questions or pending decisions"),
3989
+ impactScope: looseStringArray().optional().describe("Modules/files/areas affected by this session's work"),
3921
3990
  requestId: z.z.string().optional().describe(
3922
3991
  "Optional request identifier that runtimes may record in sessionSummaryRefresh recent history for request-level confirmation"
3923
3992
  )
@@ -3941,6 +4010,19 @@ HAPPY_MCP_TOOL_NAMES.filter(
3941
4010
  HAPPY_MCP_TOOL_NAMES.filter(
3942
4011
  (toolName) => HAPPY_MCP_TOOL_SPECS[toolName].hideSuccessfulCall
3943
4012
  );
4013
+ z.z.object({
4014
+ askId: z.z.string(),
4015
+ answers: z.z.record(z.z.string(), z.z.string()),
4016
+ // When true the user explicitly declined to answer (tapped the "取消选择"
4017
+ // / "Decline" button in the App's picker). The happy-cli handler treats
4018
+ // this as an error so the MCP tool returns isError to Claude TUI — letting
4019
+ // the model know it did not get an answer and should pick a fallback path
4020
+ // (e.g. proceed with assumptions or ask differently).
4021
+ canceled: z.z.boolean().optional()
4022
+ }).strict();
4023
+ z.z.object({
4024
+ ok: z.z.literal(true)
4025
+ }).strict();
3944
4026
 
3945
4027
  const CodexRuntimeConfigSchema = z__namespace.object({
3946
4028
  model: z__namespace.string().nullish(),
@@ -4080,11 +4162,13 @@ z.z.object({
4080
4162
  "invalid_arguments",
4081
4163
  "permission_denied",
4082
4164
  /**
4083
- * SDK 0.2.119 defines the `mcp_call` control protocol type but does
4084
- * not expose a public runtime method on the `Query` interface. Until
4085
- * upstream lands a `callMcpTool()` / equivalent, the CLI handler
4086
- * returns this code so the App can surface an honest "waiting on
4087
- * SDK" state instead of masking the gap as a server error.
4165
+ * The agent runtime has no programmatic MCP-tool invocation surface.
4166
+ * In PTY mode the Claude TUI owns the MCP connections itself; the
4167
+ * historical Claude Agent SDK also never exposed a runtime
4168
+ * `callMcpTool()` on `Query`. The CLI handler returns this code so
4169
+ * the App can surface an honest "not supported" state instead of
4170
+ * masking the gap as a server error. Code name preserved for wire
4171
+ * compatibility across older CLI / App builds.
4088
4172
  */
4089
4173
  "sdk_not_implemented",
4090
4174
  "unknown"
@@ -4427,6 +4511,21 @@ z.z.object({
4427
4511
  servers: z.z.record(z.z.string(), McpRegistryEntrySchema)
4428
4512
  });
4429
4513
 
4514
+ z__namespace.discriminatedUnion("type", [
4515
+ z__namespace.object({
4516
+ type: z__namespace.literal("success"),
4517
+ sessionId: z__namespace.string()
4518
+ }),
4519
+ z__namespace.object({
4520
+ type: z__namespace.literal("requestToApproveDirectoryCreation"),
4521
+ directory: z__namespace.string()
4522
+ }),
4523
+ z__namespace.object({
4524
+ type: z__namespace.literal("error"),
4525
+ errorMessage: z__namespace.string()
4526
+ })
4527
+ ]);
4528
+
4430
4529
  const NOT_INSTALLED = Object.freeze({ status: "not-installed" });
4431
4530
  const DISCONNECTED = Object.freeze({ status: "disconnected" });
4432
4531
  const DETECT_TIMEOUT_MS = 3e3;
@@ -4902,8 +5001,7 @@ class MachineClient {
4902
5001
  this.onEphemeral = opts.onEphemeral;
4903
5002
  this.rpcHandlerManager = new RpcHandlerManager({
4904
5003
  scopePrefix: opts.machine.id,
4905
- encryptionKey: opts.machine.encryptionKey,
4906
- encryptionVariant: opts.machine.encryptionVariant,
5004
+ cipher: createCipher(opts.machine.encryptionKey, opts.machine.encryptionVariant),
4907
5005
  logger: (msg, data) => logger.debug(msg, data)
4908
5006
  });
4909
5007
  const workDir = opts.workingDirectory ?? process.cwd();
package/dist/index.d.cts CHANGED
@@ -529,10 +529,46 @@ declare function getOrCreateMachine(config: Config, creds: Credentials, metadata
529
529
  */
530
530
  declare function listMachines(config: Config, creds: Credentials): Promise<RawMachine[]>;
531
531
 
532
+ /**
533
+ * Outcome of a decrypt attempt.
534
+ *
535
+ * The low-level {@link decrypt} returns `unknown | null`, fusing "could not
536
+ * decrypt" (wrong key, tampered bytes, wrong variant, non-JSON plaintext)
537
+ * with a value that legitimately decrypted to something falsy.
538
+ * `DecryptResult` splits those apart: `{ ok: false }` is an authentication or
539
+ * parse failure, while `{ ok: true, value }` carries the recovered plaintext.
540
+ * Callers branch on `ok` instead of guessing from a collapsed `null`.
541
+ */
542
+ type DecryptResult = {
543
+ ok: true;
544
+ value: any;
545
+ } | {
546
+ ok: false;
547
+ };
548
+ /**
549
+ * A Cipher binds one AccessKey + encryption variant into a small interface.
550
+ *
551
+ * It is the single seam every transport client encrypts/decrypts through:
552
+ * hand it a value and get a wire-ready base64 string, or hand it a base64
553
+ * wire string and get a {@link DecryptResult}. The variant choice (legacy
554
+ * NaCl secretbox vs AES-256-GCM dataKey) and the base64 framing live behind
555
+ * this interface, so call sites never thread `(key, variant)` or call
556
+ * `encode`/`decodeBase64` themselves — a wrong-variant or wrong-key bug can
557
+ * only originate at the one `createCipher` call, not at the dozens of places
558
+ * that used to repeat the pattern.
559
+ */
560
+ interface Cipher {
561
+ /** Encrypt a JSON-serializable value, returning a base64 wire string. */
562
+ encrypt(data: any): string;
563
+ /** Decode + decrypt a base64 wire string. Never throws. */
564
+ decrypt(data: string): DecryptResult;
565
+ }
566
+
532
567
  /**
533
568
  * Common RPC types and interfaces for both session and machine clients.
534
569
  * Mirrors happy-cli/src/api/rpc/types.ts
535
570
  */
571
+
536
572
  /**
537
573
  * Generic RPC handler function type
538
574
  */
@@ -549,8 +585,7 @@ interface RpcRequest {
549
585
  */
550
586
  interface RpcHandlerConfig {
551
587
  scopePrefix: string;
552
- encryptionKey: Uint8Array;
553
- encryptionVariant: "legacy" | "dataKey";
588
+ cipher: Cipher;
554
589
  logger?: (message: string, data?: unknown) => void;
555
590
  }
556
591
 
@@ -565,8 +600,7 @@ interface RpcHandlerConfig {
565
600
  declare class RpcHandlerManager {
566
601
  private handlers;
567
602
  private readonly scopePrefix;
568
- private readonly encryptionKey;
569
- private readonly encryptionVariant;
603
+ private readonly cipher;
570
604
  private readonly logger;
571
605
  private socket;
572
606
  private reregisterInterval;
@@ -577,7 +611,18 @@ declare class RpcHandlerManager {
577
611
  */
578
612
  registerHandler<TRequest = any, TResponse = any>(method: string, handler: RpcHandler<TRequest, TResponse>): void;
579
613
  /**
580
- * Handle an incoming RPC request
614
+ * Route a decrypted RPC call to its handler. This is the plaintext core of
615
+ * the manager: it knows nothing about the wire (no base64, no cipher), so it
616
+ * is TOTAL — an unknown method or a throwing handler both resolve to an
617
+ * `{ error }` value rather than rejecting. That makes it the test surface for
618
+ * routing behaviour, exercised without any crypto setup.
619
+ */
620
+ dispatch(method: string, params: unknown): Promise<unknown>;
621
+ /**
622
+ * Handle an incoming wire RPC request: decrypt params, dispatch in plaintext,
623
+ * encrypt the result. The Cipher is the only encryption seam; on a decrypt
624
+ * failure the handler is still dispatched with `null` params (preserving the
625
+ * previous behaviour where a corrupt payload decrypted to `null`).
581
626
  */
582
627
  handleRequest(request: RpcRequest): Promise<string>;
583
628
  onSocketConnect(socket: Socket): void;
package/dist/index.d.mts CHANGED
@@ -529,10 +529,46 @@ declare function getOrCreateMachine(config: Config, creds: Credentials, metadata
529
529
  */
530
530
  declare function listMachines(config: Config, creds: Credentials): Promise<RawMachine[]>;
531
531
 
532
+ /**
533
+ * Outcome of a decrypt attempt.
534
+ *
535
+ * The low-level {@link decrypt} returns `unknown | null`, fusing "could not
536
+ * decrypt" (wrong key, tampered bytes, wrong variant, non-JSON plaintext)
537
+ * with a value that legitimately decrypted to something falsy.
538
+ * `DecryptResult` splits those apart: `{ ok: false }` is an authentication or
539
+ * parse failure, while `{ ok: true, value }` carries the recovered plaintext.
540
+ * Callers branch on `ok` instead of guessing from a collapsed `null`.
541
+ */
542
+ type DecryptResult = {
543
+ ok: true;
544
+ value: any;
545
+ } | {
546
+ ok: false;
547
+ };
548
+ /**
549
+ * A Cipher binds one AccessKey + encryption variant into a small interface.
550
+ *
551
+ * It is the single seam every transport client encrypts/decrypts through:
552
+ * hand it a value and get a wire-ready base64 string, or hand it a base64
553
+ * wire string and get a {@link DecryptResult}. The variant choice (legacy
554
+ * NaCl secretbox vs AES-256-GCM dataKey) and the base64 framing live behind
555
+ * this interface, so call sites never thread `(key, variant)` or call
556
+ * `encode`/`decodeBase64` themselves — a wrong-variant or wrong-key bug can
557
+ * only originate at the one `createCipher` call, not at the dozens of places
558
+ * that used to repeat the pattern.
559
+ */
560
+ interface Cipher {
561
+ /** Encrypt a JSON-serializable value, returning a base64 wire string. */
562
+ encrypt(data: any): string;
563
+ /** Decode + decrypt a base64 wire string. Never throws. */
564
+ decrypt(data: string): DecryptResult;
565
+ }
566
+
532
567
  /**
533
568
  * Common RPC types and interfaces for both session and machine clients.
534
569
  * Mirrors happy-cli/src/api/rpc/types.ts
535
570
  */
571
+
536
572
  /**
537
573
  * Generic RPC handler function type
538
574
  */
@@ -549,8 +585,7 @@ interface RpcRequest {
549
585
  */
550
586
  interface RpcHandlerConfig {
551
587
  scopePrefix: string;
552
- encryptionKey: Uint8Array;
553
- encryptionVariant: "legacy" | "dataKey";
588
+ cipher: Cipher;
554
589
  logger?: (message: string, data?: unknown) => void;
555
590
  }
556
591
 
@@ -565,8 +600,7 @@ interface RpcHandlerConfig {
565
600
  declare class RpcHandlerManager {
566
601
  private handlers;
567
602
  private readonly scopePrefix;
568
- private readonly encryptionKey;
569
- private readonly encryptionVariant;
603
+ private readonly cipher;
570
604
  private readonly logger;
571
605
  private socket;
572
606
  private reregisterInterval;
@@ -577,7 +611,18 @@ declare class RpcHandlerManager {
577
611
  */
578
612
  registerHandler<TRequest = any, TResponse = any>(method: string, handler: RpcHandler<TRequest, TResponse>): void;
579
613
  /**
580
- * Handle an incoming RPC request
614
+ * Route a decrypted RPC call to its handler. This is the plaintext core of
615
+ * the manager: it knows nothing about the wire (no base64, no cipher), so it
616
+ * is TOTAL — an unknown method or a throwing handler both resolve to an
617
+ * `{ error }` value rather than rejecting. That makes it the test surface for
618
+ * routing behaviour, exercised without any crypto setup.
619
+ */
620
+ dispatch(method: string, params: unknown): Promise<unknown>;
621
+ /**
622
+ * Handle an incoming wire RPC request: decrypt params, dispatch in plaintext,
623
+ * encrypt the result. The Cipher is the only encryption seam; on a decrypt
624
+ * failure the handler is still dispatched with `null` params (preserving the
625
+ * previous behaviour where a corrupt payload decrypted to `null`).
581
626
  */
582
627
  handleRequest(request: RpcRequest): Promise<string>;
583
628
  onSocketConnect(socket: Socket): void;
package/dist/index.mjs CHANGED
@@ -19,7 +19,7 @@ import * as z from 'zod';
19
19
  import { z as z$1 } from 'zod';
20
20
  import { createServer } from 'http';
21
21
 
22
- var version = "0.7.1";
22
+ var version = "0.7.2";
23
23
 
24
24
  function loadConfig() {
25
25
  const serverUrl = (process.env.HAPPY_SERVER_URL ?? "https://s.sangreal.code.xycloud.info:2443").replace(/\/+$/, "");
@@ -137,6 +137,21 @@ function decrypt(key, variant, data) {
137
137
  return decryptWithDataKey(data, key);
138
138
  }
139
139
  }
140
+ function createCipher(key, variant) {
141
+ return {
142
+ encrypt(data) {
143
+ return encodeBase64(encrypt(key, variant, data));
144
+ },
145
+ decrypt(data) {
146
+ try {
147
+ const value = decrypt(key, variant, decodeBase64(data));
148
+ return value === null ? { ok: false } : { ok: true, value };
149
+ } catch {
150
+ return { ok: false };
151
+ }
152
+ }
153
+ };
154
+ }
140
155
  function libsodiumEncryptForPublicKey(data, recipientPublicKey) {
141
156
  const ephemeralKeyPair = tweetnacl.box.keyPair();
142
157
  const nonce = getRandomBytes(tweetnacl.box.nonceLength);
@@ -619,16 +634,14 @@ async function withBackoff(fn, options) {
619
634
  class RpcHandlerManager {
620
635
  handlers = /* @__PURE__ */ new Map();
621
636
  scopePrefix;
622
- encryptionKey;
623
- encryptionVariant;
637
+ cipher;
624
638
  logger;
625
639
  socket = null;
626
640
  reregisterInterval = null;
627
641
  fastRetryTimer = null;
628
642
  constructor(config) {
629
643
  this.scopePrefix = config.scopePrefix;
630
- this.encryptionKey = config.encryptionKey;
631
- this.encryptionVariant = config.encryptionVariant;
644
+ this.cipher = config.cipher;
632
645
  this.logger = config.logger ?? ((msg, data) => logger.debug(msg, data));
633
646
  }
634
647
  /**
@@ -642,45 +655,45 @@ class RpcHandlerManager {
642
655
  }
643
656
  }
644
657
  /**
645
- * Handle an incoming RPC request
658
+ * Route a decrypted RPC call to its handler. This is the plaintext core of
659
+ * the manager: it knows nothing about the wire (no base64, no cipher), so it
660
+ * is TOTAL — an unknown method or a throwing handler both resolve to an
661
+ * `{ error }` value rather than rejecting. That makes it the test surface for
662
+ * routing behaviour, exercised without any crypto setup.
646
663
  */
647
- async handleRequest(request) {
664
+ async dispatch(method, params) {
665
+ const handler = this.handlers.get(method);
666
+ if (!handler) {
667
+ this.logger("[RPC] [ERROR] Method not found", { method });
668
+ return { error: "Method not found" };
669
+ }
648
670
  try {
649
- const handler = this.handlers.get(request.method);
650
- if (!handler) {
651
- this.logger("[RPC] [ERROR] Method not found", {
652
- method: request.method
653
- });
654
- const errorResponse = { error: "Method not found" };
655
- return encodeBase64(
656
- encrypt(this.encryptionKey, this.encryptionVariant, errorResponse)
657
- );
658
- }
659
- const decryptedParams = decrypt(
660
- this.encryptionKey,
661
- this.encryptionVariant,
662
- decodeBase64(request.params)
663
- );
664
- this.logger("[RPC] Calling handler", { method: request.method });
665
- const result = await handler(decryptedParams);
671
+ this.logger("[RPC] Calling handler", { method });
672
+ const result = await handler(params);
666
673
  this.logger("[RPC] Handler returned", {
667
- method: request.method,
674
+ method,
668
675
  hasResult: result !== void 0
669
676
  });
670
- const encryptedResponse = encodeBase64(
671
- encrypt(this.encryptionKey, this.encryptionVariant, result)
672
- );
673
- return encryptedResponse;
677
+ return result;
674
678
  } catch (error) {
675
679
  this.logger("[RPC] [ERROR] Error handling request", { error });
676
- const errorResponse = {
677
- error: error instanceof Error ? error.message : "Unknown error"
678
- };
679
- return encodeBase64(
680
- encrypt(this.encryptionKey, this.encryptionVariant, errorResponse)
681
- );
680
+ return { error: error instanceof Error ? error.message : "Unknown error" };
682
681
  }
683
682
  }
683
+ /**
684
+ * Handle an incoming wire RPC request: decrypt params, dispatch in plaintext,
685
+ * encrypt the result. The Cipher is the only encryption seam; on a decrypt
686
+ * failure the handler is still dispatched with `null` params (preserving the
687
+ * previous behaviour where a corrupt payload decrypted to `null`).
688
+ */
689
+ async handleRequest(request) {
690
+ const decrypted = this.cipher.decrypt(request.params);
691
+ const result = await this.dispatch(
692
+ request.method,
693
+ decrypted.ok ? decrypted.value : null
694
+ );
695
+ return this.cipher.encrypt(result);
696
+ }
684
697
  onSocketConnect(socket) {
685
698
  this.socket = socket;
686
699
  this.registerAllHandlers(socket);
@@ -1444,8 +1457,7 @@ class SessionClient extends EventEmitter {
1444
1457
  });
1445
1458
  this.rpcHandlerManager = createRpcHandlerManager({
1446
1459
  scopePrefix: `session:${opts.sessionId}`,
1447
- encryptionKey: opts.encryptionKey,
1448
- encryptionVariant: opts.encryptionVariant,
1460
+ cipher: createCipher(opts.encryptionKey, opts.encryptionVariant),
1449
1461
  logger: (msg, data) => logger.debug(msg, data)
1450
1462
  });
1451
1463
  if (opts.enableRpc !== false) {
@@ -2653,6 +2665,17 @@ const sessionEnvelopeSchema = z.object({
2653
2665
  subagent: z.string().refine((value) => cuid2Exports.isCuid(value), {
2654
2666
  message: "subagent must be a cuid2 value"
2655
2667
  }).optional(),
2668
+ /**
2669
+ * Optional Claude-side message UUID for this envelope. Populated by the
2670
+ * CLI when an envelope mirrors a specific JSONL record, so the App can
2671
+ * use it as a precise rewind/fork anchor (the CLI's `forkSession` RPC
2672
+ * accepts this value as `upToMessageId`).
2673
+ *
2674
+ * Backward-compatible: older CLIs / non-Claude agents simply omit it.
2675
+ * App code MUST treat absence as "no fork anchor available" rather than
2676
+ * an error.
2677
+ */
2678
+ claudeUuid: z.string().min(1).optional(),
2656
2679
  ev: sessionEventSchema
2657
2680
  }).superRefine((envelope, ctx) => {
2658
2681
  if (envelope.ev.t === "service" && envelope.role !== "agent") {
@@ -3830,8 +3853,27 @@ const HAPPY_MCP_TOOL_NAMES = [
3830
3853
  "change_title",
3831
3854
  "query_project_knowledge",
3832
3855
  "update_progress",
3833
- "update_session_summary"
3856
+ "update_session_summary",
3857
+ "ask_user"
3834
3858
  ];
3859
+ const parseIfJsonString = (v) => {
3860
+ if (typeof v !== "string") return v;
3861
+ try {
3862
+ return JSON.parse(v);
3863
+ } catch {
3864
+ return v;
3865
+ }
3866
+ };
3867
+ const looseStringArray = () => z$1.union([z$1.array(z$1.string()), z$1.string()]).transform((v) => {
3868
+ if (Array.isArray(v)) return v;
3869
+ const parsed = parseIfJsonString(v);
3870
+ if (Array.isArray(parsed)) return parsed.map(String);
3871
+ return v.length ? [v] : [];
3872
+ });
3873
+ const looseObjectArray = (item) => z$1.union([
3874
+ z$1.array(item),
3875
+ z$1.string().transform(parseIfJsonString).pipe(z$1.array(item))
3876
+ ]);
3835
3877
  const HAPPY_MCP_TOOL_SPECS = {
3836
3878
  change_title: {
3837
3879
  title: "Change Title",
@@ -3866,7 +3908,7 @@ const HAPPY_MCP_TOOL_SPECS = {
3866
3908
  description: 'Optional override for the App\'s Progress tab. In most cases your TodoWrite calls are auto-mirrored, so you do NOT need to call this. Use it only when you want to set extra fields the CLI hook does not capture (currentStage, blockers) or to force a new list boundary with `listId: "new"`.',
3867
3909
  failureLabel: "Failed to update progress",
3868
3910
  inputSchema: {
3869
- todos: z$1.array(
3911
+ todos: looseObjectArray(
3870
3912
  z$1.object({
3871
3913
  content: z$1.string().describe("Concise description of the task"),
3872
3914
  status: z$1.enum(["pending", "in_progress", "completed"]).describe("Current status of the task"),
@@ -3877,7 +3919,7 @@ const HAPPY_MCP_TOOL_SPECS = {
3877
3919
  })
3878
3920
  ).describe("The full checklist \u2014 always send every item, not a delta"),
3879
3921
  currentStage: z$1.string().optional().describe("Optional overall stage name for the checklist"),
3880
- blockers: z$1.array(z$1.string()).optional().describe("Optional list of things blocking progress"),
3922
+ blockers: looseStringArray().optional().describe("Optional list of things blocking progress"),
3881
3923
  listId: z$1.string().optional().describe("Target list id. Use 'new' to force a fresh list"),
3882
3924
  label: z$1.string().optional().describe("Short human-readable name for this task list")
3883
3925
  },
@@ -3888,6 +3930,33 @@ const HAPPY_MCP_TOOL_SPECS = {
3888
3930
  fallbackAction: "Update progress",
3889
3931
  reasonPhrases: ["progress update", "progress updates", "update_progress"]
3890
3932
  },
3933
+ ask_user: {
3934
+ title: "Ask User",
3935
+ description: "Ask the user one or more questions via the App's native picker UI. Use this whenever AskUserQuestion is unavailable (the host has disabled it \u2014 common when running under happy-cli's PTY-mode Claude TUI). The input schema is identical to AskUserQuestion's. The tool blocks until the user submits answers in the App, then returns them as a JSON string keyed by question text.",
3936
+ failureLabel: "Failed to get user answer",
3937
+ inputSchema: {
3938
+ questions: looseObjectArray(
3939
+ z$1.object({
3940
+ question: z$1.string().describe("The question to ask the user"),
3941
+ header: z$1.string().describe("Short label/chip for the question (max ~12 chars)"),
3942
+ options: looseObjectArray(
3943
+ z$1.object({
3944
+ label: z$1.string().describe("Option label"),
3945
+ description: z$1.string().describe("Option description"),
3946
+ preview: z$1.string().optional().describe("Optional preview content shown when the option is focused")
3947
+ })
3948
+ ).describe("Available choices for this question (2-4 options)"),
3949
+ multiSelect: z$1.boolean().describe("Allow multiple selections instead of single-select")
3950
+ })
3951
+ ).describe("Questions to ask the user (1-4 questions)")
3952
+ },
3953
+ hideSuccessfulCall: false,
3954
+ autoApproveByDefault: false,
3955
+ permissionAction: "Waiting for user to answer",
3956
+ dynamicAction: "Waiting for user to answer",
3957
+ fallbackAction: "Ask user",
3958
+ reasonPhrases: ["ask user", "user input", "ask_user"]
3959
+ },
3891
3960
  update_session_summary: {
3892
3961
  title: "Update Session Summary",
3893
3962
  description: "Write a narrative session summary the App shows above the progress checklist. Call at milestones, not per task: after first understanding the goal, when scope shifts significantly, when key decisions are made, or when moving to a new phase. Full rewrite each call.",
@@ -3895,9 +3964,9 @@ const HAPPY_MCP_TOOL_SPECS = {
3895
3964
  inputSchema: {
3896
3965
  goal: z$1.string().describe("What the user ultimately wants to accomplish"),
3897
3966
  currentFocus: z$1.string().optional().describe("Brief description of the active task or phase"),
3898
- keyDecisions: z$1.array(z$1.string()).optional().describe("Important choices already made this session"),
3899
- openQuestions: z$1.array(z$1.string()).optional().describe("Unresolved questions or pending decisions"),
3900
- impactScope: z$1.array(z$1.string()).optional().describe("Modules/files/areas affected by this session's work"),
3967
+ keyDecisions: looseStringArray().optional().describe("Important choices already made this session"),
3968
+ openQuestions: looseStringArray().optional().describe("Unresolved questions or pending decisions"),
3969
+ impactScope: looseStringArray().optional().describe("Modules/files/areas affected by this session's work"),
3901
3970
  requestId: z$1.string().optional().describe(
3902
3971
  "Optional request identifier that runtimes may record in sessionSummaryRefresh recent history for request-level confirmation"
3903
3972
  )
@@ -3921,6 +3990,19 @@ HAPPY_MCP_TOOL_NAMES.filter(
3921
3990
  HAPPY_MCP_TOOL_NAMES.filter(
3922
3991
  (toolName) => HAPPY_MCP_TOOL_SPECS[toolName].hideSuccessfulCall
3923
3992
  );
3993
+ z$1.object({
3994
+ askId: z$1.string(),
3995
+ answers: z$1.record(z$1.string(), z$1.string()),
3996
+ // When true the user explicitly declined to answer (tapped the "取消选择"
3997
+ // / "Decline" button in the App's picker). The happy-cli handler treats
3998
+ // this as an error so the MCP tool returns isError to Claude TUI — letting
3999
+ // the model know it did not get an answer and should pick a fallback path
4000
+ // (e.g. proceed with assumptions or ask differently).
4001
+ canceled: z$1.boolean().optional()
4002
+ }).strict();
4003
+ z$1.object({
4004
+ ok: z$1.literal(true)
4005
+ }).strict();
3924
4006
 
3925
4007
  const CodexRuntimeConfigSchema = z.object({
3926
4008
  model: z.string().nullish(),
@@ -4060,11 +4142,13 @@ z$1.object({
4060
4142
  "invalid_arguments",
4061
4143
  "permission_denied",
4062
4144
  /**
4063
- * SDK 0.2.119 defines the `mcp_call` control protocol type but does
4064
- * not expose a public runtime method on the `Query` interface. Until
4065
- * upstream lands a `callMcpTool()` / equivalent, the CLI handler
4066
- * returns this code so the App can surface an honest "waiting on
4067
- * SDK" state instead of masking the gap as a server error.
4145
+ * The agent runtime has no programmatic MCP-tool invocation surface.
4146
+ * In PTY mode the Claude TUI owns the MCP connections itself; the
4147
+ * historical Claude Agent SDK also never exposed a runtime
4148
+ * `callMcpTool()` on `Query`. The CLI handler returns this code so
4149
+ * the App can surface an honest "not supported" state instead of
4150
+ * masking the gap as a server error. Code name preserved for wire
4151
+ * compatibility across older CLI / App builds.
4068
4152
  */
4069
4153
  "sdk_not_implemented",
4070
4154
  "unknown"
@@ -4407,6 +4491,21 @@ z$1.object({
4407
4491
  servers: z$1.record(z$1.string(), McpRegistryEntrySchema)
4408
4492
  });
4409
4493
 
4494
+ z.discriminatedUnion("type", [
4495
+ z.object({
4496
+ type: z.literal("success"),
4497
+ sessionId: z.string()
4498
+ }),
4499
+ z.object({
4500
+ type: z.literal("requestToApproveDirectoryCreation"),
4501
+ directory: z.string()
4502
+ }),
4503
+ z.object({
4504
+ type: z.literal("error"),
4505
+ errorMessage: z.string()
4506
+ })
4507
+ ]);
4508
+
4410
4509
  const NOT_INSTALLED = Object.freeze({ status: "not-installed" });
4411
4510
  const DISCONNECTED = Object.freeze({ status: "disconnected" });
4412
4511
  const DETECT_TIMEOUT_MS = 3e3;
@@ -4882,8 +4981,7 @@ class MachineClient {
4882
4981
  this.onEphemeral = opts.onEphemeral;
4883
4982
  this.rpcHandlerManager = new RpcHandlerManager({
4884
4983
  scopePrefix: opts.machine.id,
4885
- encryptionKey: opts.machine.encryptionKey,
4886
- encryptionVariant: opts.machine.encryptionVariant,
4984
+ cipher: createCipher(opts.machine.encryptionKey, opts.machine.encryptionVariant),
4887
4985
  logger: (msg, data) => logger.debug(msg, data)
4888
4986
  });
4889
4987
  const workDir = opts.workingDirectory ?? process.cwd();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kmmao/happy-agent",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "description": "CLI client for controlling Happy Coder agents remotely",
5
5
  "author": "Kirill Dubovitskiy",
6
6
  "license": "MIT",
@@ -43,7 +43,7 @@
43
43
  "release": "npx --no-install release-it"
44
44
  },
45
45
  "dependencies": {
46
- "@kmmao/happy-wire": "^0.22.0",
46
+ "@kmmao/happy-wire": "^0.24.0",
47
47
  "axios": "^1.15.2",
48
48
  "chalk": "^5.6.2",
49
49
  "commander": "^14.0.0",