zigmini 0.8.0-beta.2 → 0.8.0-beta.3

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.
@@ -17,3 +17,4 @@ zigmini 패키지 공통 에이전트 규칙입니다. 게이트웨이 브리지
17
17
  12. 로컬 파일 경로를 웹 링크처럼 보여 주지 않습니다.
18
18
  13. 사용자의 시간 표현은 사용자의 현지 시간으로 해석합니다. 보고하는 시각도 현지 시간으로 씁니다.
19
19
  14. 사용자는 게이트웨이 프로필의 닉네임으로 부릅니다.
20
+ 15. main은 오래 기다리거나 오래 실행하는 작업(배포, 전체 테스트, 뮤테이션 테스트, 다른 작업 기다리기)을 맡기면, 그 작업의 세션 기록이 30분 넘게 바뀌지 않을 때 main을 깨우는 감시를 직접 겁니다. 작업이 끝나면 감시를 끕니다. 작업의 "기다리는 중"·"진행 중" 보고만 믿지 않습니다. 사용자에게 그렇게 말하기 전에 그 작업의 마지막 기록 시각과 실행 중인 프로세스를 확인합니다. 작업이 멈췄으면 바로 그 작업에 조치를 보내고 사용자에게 알립니다. 작업은 긴 명령을 세션과 분리해 실행하고, 결과 파일을 직접 확인하며, 시간 상한을 겁니다.
Binary file
@@ -1,5 +1,5 @@
1
1
  {
2
- "versionCode": 21,
3
- "versionName": "0.8.0-beta.2",
4
- "sha256": "b674f81288b1a6e6c6e1cfb5506dc1a69051267488c811b42648df3540c48e1d"
2
+ "versionCode": 22,
3
+ "versionName": "0.8.0-beta.3",
4
+ "sha256": "819f50b65105dde875324e7dde62635321ee1412ba1e975849a764f10dea3426"
5
5
  }
package/assets/build.json CHANGED
@@ -1 +1 @@
1
- {"commit":"7853cd4f5a3c62d2a79ac8dc0b94786087e06d8a"}
1
+ {"commit":"d6641bcbb5a3d30fe55d94c24f05113f3405518b"}
@@ -47,8 +47,11 @@ function commandEntry(entries: Entry[], workId: string, seq: unknown): Params {
47
47
  throw new Error("report_command_not_received");
48
48
  }
49
49
 
50
- /** Main models have sent report details as a JSON string; only a string that parses to an object is unwrapped. */
51
- function mainReportDetails(details: unknown): unknown {
50
+ /**
51
+ * Main and work models have sent report details as a JSON string (bugs 3 and 127); only a string that parses to an
52
+ * object is unwrapped.
53
+ */
54
+ function reportDetails(details: unknown): unknown {
52
55
  if (typeof details !== "string") return details;
53
56
  try {
54
57
  const parsed: unknown = JSON.parse(details);
@@ -204,7 +207,7 @@ export default function taskControl(pi: ExtensionApi): void {
204
207
  Object.keys(params).some((key) => !["kind", "summary", "details"].includes(key)))) {
205
208
  throw new Error("main_question_only");
206
209
  }
207
- const details = isMain ? mainReportDetails(params.details) : params.details;
210
+ const details = reportDetails(params.details);
208
211
  const command = isMain
209
212
  ? mainDecisionEntry(ctx.sessionManager.getEntries(), details)
210
213
  : commandEntry(ctx.sessionManager.getEntries(), workId as string, params.command_seq);
@@ -214,7 +217,7 @@ export default function taskControl(pi: ExtensionApi): void {
214
217
  kind: "question", summary: params.summary, details,
215
218
  } : {
216
219
  report_id: requestKey, work_id: workId, command_id: command.command_id,
217
- kind: params.kind, command_seq: params.command_seq, summary: params.summary, details: params.details,
220
+ kind: params.kind, command_seq: params.command_seq, summary: params.summary, details,
218
221
  };
219
222
  let entry = ctx.sessionManager.getEntries().find((e) => e.type === "custom" && e.customType === "zgw.work_report" && record(e.data) && e.data.report_id === requestKey);
220
223
  if (entry && !isDeepStrictEqual(entry.data, data)) throw new Error("report_key_conflict");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zigmini",
3
- "version": "0.8.0-beta.2",
3
+ "version": "0.8.0-beta.3",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "zigmini": "bin/zigmini"
@@ -53,7 +53,7 @@
53
53
  "test:e2e": "bun test test/e2e",
54
54
  "test:contract": "bun test test/contract",
55
55
  "test:all": "tsc --noEmit && bun test test/unit && bun test test/contract",
56
- "test:mutation": "bun test test/mutation",
56
+ "test:mutation": "bun scripts/mutation-run.ts",
57
57
  "heavy": "bun host/heavy-slot.ts run"
58
58
  },
59
59
  "devDependencies": {
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Bug 119: which chat socket gets which live event. Every chat socket joins both topics; the app (hello client "android",
3
+ * the same in every app version) leaves the thinking topic, since it never shows thinking and each delta costs it work on
4
+ * its main thread. The web page and devlog show thinking and stay on it.
5
+ */
6
+ export const CHAT_TOPIC = "clients";
7
+ export const THINKING_TOPIC = "thinking";
8
+ export const APP_CLIENT = "android";
9
+
10
+ export function topicOf(type: string): string {
11
+ return type === "thinking.delta" ? THINKING_TOPIC : CHAT_TOPIC;
12
+ }
13
+
14
+ export function showsThinking(client: unknown): boolean {
15
+ return client !== APP_CLIENT;
16
+ }
package/src/decisions.ts CHANGED
@@ -44,6 +44,16 @@ function unresolved(decision: Decision): boolean {
44
44
  return decision.status !== "closed" && decision.status !== "delivered";
45
45
  }
46
46
 
47
+ /**
48
+ * Bug 127: an unanswered ask_user_question with waitForAnswer false. Its tool call already returned, so its owner never
49
+ * waits on it; when its native wait ends (a generation drain, an owner release) the answer still reaches the owner as a
50
+ * follow-up command.
51
+ */
52
+ function detachedQuestion(decision: Decision): boolean {
53
+ return decision.method === "question" && !decision.permission && decision.submissionId === null &&
54
+ record(decision.payload) && decision.payload.waitForAnswer === false;
55
+ }
56
+
47
57
  /** Chat card footer for an item the user deleted from the inbox (web markResolved, Android resolvedLabel). */
48
58
  const DISMISSED_REASONS: Record<string, string> = {
49
59
  user_cancelled: "확인함에서 삭제해 질문을 취소했습니다",
@@ -304,6 +314,8 @@ export class DecisionService {
304
314
  ...observation.payload, ...(workId === null ? { sourceEventSeq } : {}),
305
315
  ...(Array.isArray(sourceReferences) && sourceReferences.length ? { sourceReferences } : {}),
306
316
  };
317
+ // A decision stored before bug 127 has no waitForAnswer mark; observing it again must not read as a changed question.
318
+ if (decision && record(decision.payload) && !Object.hasOwn(decision.payload, "waitForAnswer")) delete payload.waitForAnswer;
307
319
  const bindingState = observation.permission && record(payload.nativePermission) &&
308
320
  !this.store.hasNativePermissionAsked(owner.scope, owner.sessionPath, payload.nativePermission) ? "unknown" : "live";
309
321
  if (!decision) {
@@ -358,7 +370,8 @@ export class DecisionService {
358
370
  if (observation.method !== "question" || observation.permission || observation.required === false || typeof toolCallId !== "string" || !toolCallId) return null;
359
371
  const siblings = this.store.allDecisions(owner.scope).filter((decision) => decision.sourceSessionPath === owner.sessionPath &&
360
372
  decision.method === "question" && !decision.permission && record(decision.payload) && decision.payload.toolCallId === toolCallId && unresolved(decision));
361
- const waiting = siblings.filter((decision) => decision.required && decision.bindingState === "unknown" && decision.reason === "owner_disconnected" &&
373
+ const waiting = siblings.filter((decision) => ((decision.required && decision.bindingState === "unknown" && decision.reason === "owner_disconnected") ||
374
+ decision.reason === "async_wait_closed") &&
362
375
  decision.submissionId === null && decision.answer === null && record(decision.payload) &&
363
376
  isDeepStrictEqual(decision.payload.questions, observation.payload.questions));
364
377
  if (siblings.length !== 1 || waiting.length !== 1) return null;
@@ -413,15 +426,17 @@ export class DecisionService {
413
426
  decision.submissionId !== null && record(answer) && record(answer.answers) && receiptOutcome &&
414
427
  isDeepStrictEqual(event.answers, answer.answers) && (event.comment ?? "") === (answer.comment ?? "") &&
415
428
  Array.isArray(event.unanswered) && event.unanswered.length === 0;
429
+ const detached = !received && event.outcome !== "timed_out" && detachedQuestion(decision);
416
430
  decision = this.store.setDecisionBinding(decision.id, decision.revision, {
417
431
  state: event.outcome === "timed_out" ? "expired" : "closed",
418
- reason: event.outcome === "timed_out" ? "timeout" : "engine_wait_closed",
432
+ reason: event.outcome === "timed_out" ? "timeout" : detached ? "async_wait_closed" : "engine_wait_closed",
419
433
  });
420
434
  if (received) {
421
435
  this.store.transitionDecision(decision.id, decision.revision, { status: "delivered", reason: "question_receipt" });
422
436
  } else if (decision.submissionId !== null) {
423
437
  this.review(decision.id, record(answer) && answer.cancelled === true ? "user_deferred" : "receipt_unknown");
424
- } else if (event.outcome !== "timed_out") {
438
+ } else if (event.outcome !== "timed_out" && !detached) {
439
+ // A detached question stays pending: its answer goes by resume, as a follow-up command.
425
440
  this.review(decision.id, "engine_wait_closed");
426
441
  }
427
442
  this.refresh();
@@ -446,7 +461,8 @@ export class DecisionService {
446
461
  for (const decision of this.store.allDecisions(owner.scope)) {
447
462
  if (decision.sourceSessionPath !== owner.sessionPath || decision.method !== "question" ||
448
463
  decision.bindingState !== "live" || !unresolved(decision) || ids.has(decision.sourceRequestId)) continue;
449
- this.store.setDecisionBinding(decision.id, decision.revision, { state: "unknown", reason: "request_missing" });
464
+ this.store.setDecisionBinding(decision.id, decision.revision, detachedQuestion(decision)
465
+ ? { state: "closed", reason: "async_wait_closed" } : { state: "unknown", reason: "request_missing" });
450
466
  }
451
467
  this.refresh();
452
468
  }
@@ -468,7 +484,8 @@ export class DecisionService {
468
484
  if (this.stopped) return;
469
485
  for (const decision of this.store.allDecisions(owner.scope)) {
470
486
  if (decision.sourceSessionPath !== owner.sessionPath || decision.bindingState !== "live" || !unresolved(decision)) continue;
471
- this.store.setDecisionBinding(decision.id, decision.revision, { state: "unknown", reason: "owner_disconnected" });
487
+ this.store.setDecisionBinding(decision.id, decision.revision, detachedQuestion(decision)
488
+ ? { state: "closed", reason: "async_wait_closed" } : { state: "unknown", reason: "owner_disconnected" });
472
489
  }
473
490
  this.refresh();
474
491
  }
package/src/hub.ts CHANGED
@@ -186,6 +186,7 @@ interface QuestionItem {
186
186
  export interface ScopedQuestionFrame {
187
187
  id: string;
188
188
  toolCallId?: string;
189
+ waitForAnswer?: boolean;
189
190
  questions: QuestionItem[];
190
191
  askedAtMs?: number;
191
192
  deadlineAtMs?: number;
@@ -1786,7 +1787,8 @@ export class Hub {
1786
1787
  const decision = this.decisions?.observe(owner, {
1787
1788
  actionId, sourceRequestId: frame.id, method: "question", permission: false,
1788
1789
  payload: { title: card.title, detail: card.detail, options: [], questions: frame.questions,
1789
- ...(frame.toolCallId ? { toolCallId: frame.toolCallId } : {}) },
1790
+ ...(frame.toolCallId ? { toolCallId: frame.toolCallId } : {}),
1791
+ ...(frame.waitForAnswer === false ? { waitForAnswer: false } : {}) },
1790
1792
  deadlineAt: frame.deadlineAtMs ?? null,
1791
1793
  });
1792
1794
  const open = this.openQuestionCards(owner.conv, owner.scope);
package/src/main.ts CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  SYSTEM_NETWORK,
30
30
  } from "./enroll.ts";
31
31
  import { ACCESS_PATH, handleAccessApi, pingAccess, systemTailscale } from "./cli/access.ts";
32
+ import { CHAT_TOPIC, showsThinking, THINKING_TOPIC, topicOf } from "./client-topics.ts";
32
33
  import { APK_DIR, APK_PATH, APP_DOWNLOAD_LINK_PATH, APP_VERSION_PATH, ApkSource, appVersion, createDownloadLink, DownloadLinks, serveApk } from "./app-download.ts";
33
34
  import { loadConfig } from "./config.ts";
34
35
  import { memoUpdatedData, parseMemoPatch } from "./memo-images.ts";
@@ -142,7 +143,6 @@ const updateApi: UpdateApiDeps = {
142
143
  now: () => new Date(),
143
144
  };
144
145
 
145
- const TOPIC = "clients";
146
146
  let publish: (envelope: Envelope) => void = () => {};
147
147
  const hub = new Hub(
148
148
  store,
@@ -397,6 +397,7 @@ async function handleClient(ws: ServerWebSocket<Conn>, raw: string): Promise<voi
397
397
  // question cannot race into a phone notification between those adjacent frames.
398
398
  questionNotifications.setWebPresence(ws, true);
399
399
  }
400
+ if (!showsThinking(msg.data?.client)) ws.unsubscribe(THINKING_TOPIC);
400
401
  const state = hub.snapshot();
401
402
  const snapshot = { ...state, approvals: omoCheckNotifications.filterApprovals(state.approvals) };
402
403
  // While omo is reconnecting, Hub intentionally clears its in-memory question cards until
@@ -806,7 +807,10 @@ async function routeApi(req: Request, url: URL): Promise<Response> {
806
807
 
807
808
  const websocket: WebSocketHandler<Conn> = {
808
809
  open(ws) {
809
- if (!ws.data.notifyOnly) ws.subscribe(TOPIC);
810
+ if (!ws.data.notifyOnly) {
811
+ ws.subscribe(CHAT_TOPIC);
812
+ ws.subscribe(THINKING_TOPIC);
813
+ }
810
814
  if (ws.data.trusted && !ws.data.notifyOnly) ws.subscribe(TRUSTED_TOPIC);
811
815
  const id = ws.data.deviceId;
812
816
  if (id) {
@@ -837,8 +841,9 @@ publish = (e) => {
837
841
  if (!omoCheckNotifications.allow(e)) return;
838
842
  questionNotifications.onEnvelope(e);
839
843
  const text = JSON.stringify(e);
840
- trustedServer.publish(TOPIC, text);
841
- lanServer?.publish(TOPIC, text);
844
+ const topic = topicOf(e.type);
845
+ trustedServer.publish(topic, text);
846
+ lanServer?.publish(topic, text);
842
847
  // A decision change can change a work's 입력 대기 state (its open decisions, or its status after a delivered answer).
843
848
  if (e.type === "activity.updated" || e.type === "conversation.created" || e.type === "decision.updated") workStatus.schedule();
844
849
  };
package/src/store.ts CHANGED
@@ -215,7 +215,7 @@ export interface WorkStartRequest extends WorkCommandRequest {
215
215
  export type WorkReport = Pick<WorkUpdate, "sourceKey" | "kind" | "commandSeq" | "summary" | "details">;
216
216
 
217
217
  export class WorkStoreError extends Error {
218
- constructor(readonly code: "work_not_found" | "command_not_found" | "request_key_conflict" | "report_conflict" | "invalid_report_command" | "invalid_delivery_transition" | "invalid_publish" | "publish_conflict" | "unresolved_work_commands" | "required_decisions_pending" | "question_without_decision", readonly ids: readonly string[] = []) {
218
+ constructor(readonly code: "work_not_found" | "command_not_found" | "request_key_conflict" | "report_conflict" | "invalid_report_command" | "invalid_delivery_transition" | "invalid_publish" | "publish_conflict" | "unresolved_work_commands" | "required_decisions_pending" | "question_without_decision" | "invalid_replacement_details", readonly ids: readonly string[] = []) {
219
219
  super(code);
220
220
  }
221
221
  }
@@ -420,8 +420,12 @@ export function replacementQuestionPayload(payload: Record<string, unknown>): Re
420
420
  options: Array.isArray(withIds.options) ? withIds.options : [] };
421
421
  }
422
422
 
423
- /** Closed bindings whose question still takes an answer, delivered to the owner as a follow-up command. */
424
- export const RESUMABLE_CLOSED: ReadonlySet<string> = new Set(["report_only", "main_released"]);
423
+ /**
424
+ * Closed bindings whose question still takes an answer, delivered to the owner as a follow-up command. Bug 127:
425
+ * async_wait_closed (a waitForAnswer false question whose native wait ended) and recheck_incomplete (a recheck turn
426
+ * ended without a replacement; the question stands as asked). A permission is never resumed (submitDecision, info).
427
+ */
428
+ export const RESUMABLE_CLOSED: ReadonlySet<string> = new Set(["report_only", "main_released", "async_wait_closed", "recheck_incomplete"]);
425
429
 
426
430
  /** Checks structural completeness, not the semantic appropriateness of a historical answer. */
427
431
  function completeDecisionAnswer(method: DecisionMethod, payload: unknown, response: unknown): boolean {
@@ -1374,7 +1378,14 @@ export class Store {
1374
1378
  if (command && report.kind !== "observation" && (command.delivery === "pending" || command.delivery === "sending" || command.delivery === "unknown")) {
1375
1379
  this.setWorkCommandDelivery(command.id, "accepted", { disposition: "work_report" });
1376
1380
  }
1377
- if (command && report.kind === "question" && object(report.details) && typeof report.details.replaces_decision_id === "string") {
1381
+ // Bug 127: a question report on a recheck command must carry its replacement as an object; anything else would be
1382
+ // accepted without replacing and leave the old question recheck-only.
1383
+ const replacing = object(report.details) && typeof report.details.replaces_decision_id === "string";
1384
+ if (command && report.kind === "question" && !replacing && object(command.payload) &&
1385
+ (command.payload.decision_operation === "recheck" || command.payload.decision_operation === "request_permission")) {
1386
+ throw new WorkStoreError("invalid_replacement_details", [workId]);
1387
+ }
1388
+ if (command && report.kind === "question" && replacing) {
1378
1389
  this.replaceDecisionFromReport({
1379
1390
  scope: `work:${workId}`, sourceSessionPath: work.sessionPath,
1380
1391
  reportId: report.sourceKey, commandId: command.id, details: report.details,
package/src/work.ts CHANGED
@@ -85,6 +85,8 @@ const REPORT_RULE = "Report completed or failed with the highest command_seq of
85
85
  const QUESTION_WITHOUT_DECISION = "No inbox question is open for this work. Ask the user with ask_user_question first " +
86
86
  "(waitForAnswer true for a required confirmation, which then needs no kind=question report; " +
87
87
  "waitForAnswer false only if the work may continue without the answer and you also report), then send kind=question again. A kind=question report alone creates no inbox question.";
88
+ const INVALID_REPLACEMENT_DETAILS = "This report answers a recheck, so details must be a JSON object, not a string: " +
89
+ "{replaces_decision_id, submission_id, and replacement:{method,payload,required} or replacement_decision_id}. Send the report again with details as an object.";
88
90
  function envelope(command: WorkCommand): string {
89
91
  return PREFIX + JSON.stringify({ version: 1, work_id: command.workId, command_id: command.id, command_seq: command.seq, kind: command.kind, payload: command.payload, report_rule: REPORT_RULE });
90
92
  }
@@ -155,7 +157,9 @@ export class WorkRouter {
155
157
  } catch (error) {
156
158
  if (error instanceof RoutingError) return Response.json({ error: error.code }, { status: error.status });
157
159
  if (error instanceof WorkStoreError) return Response.json({ error: error.code, ids: error.ids,
158
- ...(error.code === "question_without_decision" ? { detail: QUESTION_WITHOUT_DECISION } : {}) }, { status: 409 });
160
+ ...(error.code === "question_without_decision" ? { detail: QUESTION_WITHOUT_DECISION } : {}),
161
+ ...(error.code === "invalid_replacement_details" ? { detail: INVALID_REPLACEMENT_DETAILS } : {}) },
162
+ { status: error.code === "invalid_replacement_details" ? 400 : 409 });
159
163
  if (error instanceof DecisionStoreError) return Response.json({ error: error.code }, {
160
164
  status: error.code === "decision_not_found" ? 404 : error.code.startsWith("invalid_") ? 400 : 409,
161
165
  });
@@ -601,7 +605,7 @@ export class WorkRouter {
601
605
  } catch (error) {
602
606
  // The live report call already showed the worker this refusal; recovery skips the entry.
603
607
  // Unresolved commands retain their receipt state; completion needs a new valid report.
604
- if (error instanceof WorkStoreError && ["question_without_decision", "unresolved_work_commands"].includes(error.code)) continue;
608
+ if (error instanceof WorkStoreError && ["question_without_decision", "unresolved_work_commands", "invalid_replacement_details"].includes(error.code)) continue;
605
609
  if (!(error instanceof WorkStoreError) || error.code !== "required_decisions_pending") throw error;
606
610
  deferred = Math.min(deferred, index);
607
611
  }