crewly 1.20.295 → 1.20.297

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 (152) hide show
  1. package/config/skills/_common/lib.sh +6 -0
  2. package/config/skills/agent/core/app-data/SKILL.md +13 -1
  3. package/config/skills/agent/core/app-data/execute.sh +60 -6
  4. package/config/skills/agent/core/app-data/execute.test.sh +42 -0
  5. package/config/skills/agent/core/complete-task/SKILL.md +4 -2
  6. package/config/skills/agent/core/complete-task/execute.sh +7 -0
  7. package/config/skills/agent/core/complete-task/explicit-id.test.sh +56 -0
  8. package/config/skills/agent/core/docs-comment/SKILL.md +49 -4
  9. package/config/skills/agent/core/docs-comment/execute.sh +38 -2
  10. package/config/skills/agent/core/docs-comment/execute.test.sh +28 -0
  11. package/config/skills/agent/core/poll-tasks/execute.sh +13 -1
  12. package/config/skills/agent/core/poll-tasks/execute.test.sh +37 -0
  13. package/config/skills/team-leader/delegate-task/execute.sh +13 -2
  14. package/config/skills/team-leader/verify-output/SKILL.md +9 -0
  15. package/dist/backend/backend/src/constants.d.ts +39 -0
  16. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  17. package/dist/backend/backend/src/constants.js +39 -0
  18. package/dist/backend/backend/src/constants.js.map +1 -1
  19. package/dist/backend/backend/src/controllers/apps/apps.controller.d.ts +2 -0
  20. package/dist/backend/backend/src/controllers/apps/apps.controller.d.ts.map +1 -1
  21. package/dist/backend/backend/src/controllers/apps/apps.controller.js +2 -0
  22. package/dist/backend/backend/src/controllers/apps/apps.controller.js.map +1 -1
  23. package/dist/backend/backend/src/controllers/apps/apps.routes.d.ts +1 -0
  24. package/dist/backend/backend/src/controllers/apps/apps.routes.d.ts.map +1 -1
  25. package/dist/backend/backend/src/controllers/apps/apps.routes.js +3 -1
  26. package/dist/backend/backend/src/controllers/apps/apps.routes.js.map +1 -1
  27. package/dist/backend/backend/src/controllers/browser/browser.controller.d.ts +20 -0
  28. package/dist/backend/backend/src/controllers/browser/browser.controller.d.ts.map +1 -1
  29. package/dist/backend/backend/src/controllers/browser/browser.controller.js +52 -9
  30. package/dist/backend/backend/src/controllers/browser/browser.controller.js.map +1 -1
  31. package/dist/backend/backend/src/controllers/google/google.controller.d.ts +31 -0
  32. package/dist/backend/backend/src/controllers/google/google.controller.d.ts.map +1 -1
  33. package/dist/backend/backend/src/controllers/google/google.controller.js +198 -5
  34. package/dist/backend/backend/src/controllers/google/google.controller.js.map +1 -1
  35. package/dist/backend/backend/src/controllers/google/google.routes.d.ts +4 -0
  36. package/dist/backend/backend/src/controllers/google/google.routes.d.ts.map +1 -1
  37. package/dist/backend/backend/src/controllers/google/google.routes.js +9 -1
  38. package/dist/backend/backend/src/controllers/google/google.routes.js.map +1 -1
  39. package/dist/backend/backend/src/controllers/monitoring/terminal.controller.d.ts.map +1 -1
  40. package/dist/backend/backend/src/controllers/monitoring/terminal.controller.js +28 -8
  41. package/dist/backend/backend/src/controllers/monitoring/terminal.controller.js.map +1 -1
  42. package/dist/backend/backend/src/controllers/slack/slack.controller.d.ts.map +1 -1
  43. package/dist/backend/backend/src/controllers/slack/slack.controller.js +28 -1
  44. package/dist/backend/backend/src/controllers/slack/slack.controller.js.map +1 -1
  45. package/dist/backend/backend/src/index.d.ts.map +1 -1
  46. package/dist/backend/backend/src/index.js +35 -5
  47. package/dist/backend/backend/src/index.js.map +1 -1
  48. package/dist/backend/backend/src/services/agent/agent-registration.service.d.ts.map +1 -1
  49. package/dist/backend/backend/src/services/agent/agent-registration.service.js +4 -2
  50. package/dist/backend/backend/src/services/agent/agent-registration.service.js.map +1 -1
  51. package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.js +1 -1
  52. package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.js.map +1 -1
  53. package/dist/backend/backend/src/services/apps/app-wake-message.d.ts +33 -0
  54. package/dist/backend/backend/src/services/apps/app-wake-message.d.ts.map +1 -1
  55. package/dist/backend/backend/src/services/apps/app-wake-message.js +48 -0
  56. package/dist/backend/backend/src/services/apps/app-wake-message.js.map +1 -1
  57. package/dist/backend/backend/src/services/apps/apps.service.d.ts +23 -0
  58. package/dist/backend/backend/src/services/apps/apps.service.d.ts.map +1 -1
  59. package/dist/backend/backend/src/services/apps/apps.service.js +57 -6
  60. package/dist/backend/backend/src/services/apps/apps.service.js.map +1 -1
  61. package/dist/backend/backend/src/services/google/docs-comment-gate.d.ts +166 -0
  62. package/dist/backend/backend/src/services/google/docs-comment-gate.d.ts.map +1 -0
  63. package/dist/backend/backend/src/services/google/docs-comment-gate.js +176 -0
  64. package/dist/backend/backend/src/services/google/docs-comment-gate.js.map +1 -0
  65. package/dist/backend/backend/src/services/google/docs-comments.service.d.ts.map +1 -1
  66. package/dist/backend/backend/src/services/google/docs-comments.service.js +2 -2
  67. package/dist/backend/backend/src/services/google/docs-comments.service.js.map +1 -1
  68. package/dist/backend/backend/src/services/google/google-api.client.d.ts +15 -0
  69. package/dist/backend/backend/src/services/google/google-api.client.d.ts.map +1 -1
  70. package/dist/backend/backend/src/services/google/google-api.client.js +16 -2
  71. package/dist/backend/backend/src/services/google/google-api.client.js.map +1 -1
  72. package/dist/backend/backend/src/services/google/google-reauth.wiring.js +1 -1
  73. package/dist/backend/backend/src/services/google/google-reauth.wiring.js.map +1 -1
  74. package/dist/backend/backend/src/services/google/google-workspace-token.service.d.ts +12 -0
  75. package/dist/backend/backend/src/services/google/google-workspace-token.service.d.ts.map +1 -1
  76. package/dist/backend/backend/src/services/google/google-workspace-token.service.js +18 -0
  77. package/dist/backend/backend/src/services/google/google-workspace-token.service.js.map +1 -1
  78. package/dist/backend/backend/src/services/messaging/sub-agent-message-queue.service.d.ts +18 -4
  79. package/dist/backend/backend/src/services/messaging/sub-agent-message-queue.service.d.ts.map +1 -1
  80. package/dist/backend/backend/src/services/messaging/sub-agent-message-queue.service.js +36 -8
  81. package/dist/backend/backend/src/services/messaging/sub-agent-message-queue.service.js.map +1 -1
  82. package/dist/backend/backend/src/services/orc/reply-destination-resolver.d.ts +6 -0
  83. package/dist/backend/backend/src/services/orc/reply-destination-resolver.d.ts.map +1 -1
  84. package/dist/backend/backend/src/services/orc/reply-destination-resolver.js +18 -0
  85. package/dist/backend/backend/src/services/orc/reply-destination-resolver.js.map +1 -1
  86. package/dist/backend/backend/src/services/orc/reply-destination.wiring.d.ts +2 -0
  87. package/dist/backend/backend/src/services/orc/reply-destination.wiring.d.ts.map +1 -1
  88. package/dist/backend/backend/src/services/orc/reply-destination.wiring.js +81 -8
  89. package/dist/backend/backend/src/services/orc/reply-destination.wiring.js.map +1 -1
  90. package/dist/backend/backend/src/services/project-tickets/project-ticket-workflow.service.d.ts +8 -0
  91. package/dist/backend/backend/src/services/project-tickets/project-ticket-workflow.service.d.ts.map +1 -1
  92. package/dist/backend/backend/src/services/project-tickets/project-ticket-workflow.service.js +22 -0
  93. package/dist/backend/backend/src/services/project-tickets/project-ticket-workflow.service.js.map +1 -1
  94. package/dist/backend/backend/src/services/session/session-command-helper.d.ts +17 -0
  95. package/dist/backend/backend/src/services/session/session-command-helper.d.ts.map +1 -1
  96. package/dist/backend/backend/src/services/session/session-command-helper.js +32 -0
  97. package/dist/backend/backend/src/services/session/session-command-helper.js.map +1 -1
  98. package/dist/backend/backend/src/services/slack/orc-owner-dm-target.d.ts +27 -0
  99. package/dist/backend/backend/src/services/slack/orc-owner-dm-target.d.ts.map +1 -0
  100. package/dist/backend/backend/src/services/slack/orc-owner-dm-target.js +48 -0
  101. package/dist/backend/backend/src/services/slack/orc-owner-dm-target.js.map +1 -0
  102. package/dist/backend/backend/src/services/task-pool/pool-archive-migration.d.ts +18 -0
  103. package/dist/backend/backend/src/services/task-pool/pool-archive-migration.d.ts.map +1 -1
  104. package/dist/backend/backend/src/services/task-pool/pool-archive-migration.js +9 -3
  105. package/dist/backend/backend/src/services/task-pool/pool-archive-migration.js.map +1 -1
  106. package/dist/backend/backend/src/services/task-pool/pool-prune.d.ts +76 -0
  107. package/dist/backend/backend/src/services/task-pool/pool-prune.d.ts.map +1 -0
  108. package/dist/backend/backend/src/services/task-pool/pool-prune.js +129 -0
  109. package/dist/backend/backend/src/services/task-pool/pool-prune.js.map +1 -0
  110. package/dist/backend/backend/src/services/task-pool/pool-storage.d.ts +9 -1
  111. package/dist/backend/backend/src/services/task-pool/pool-storage.d.ts.map +1 -1
  112. package/dist/backend/backend/src/services/task-pool/pool-storage.js +34 -3
  113. package/dist/backend/backend/src/services/task-pool/pool-storage.js.map +1 -1
  114. package/dist/backend/backend/src/services/worktree/runaway-file-watcher.d.ts +131 -0
  115. package/dist/backend/backend/src/services/worktree/runaway-file-watcher.d.ts.map +1 -0
  116. package/dist/backend/backend/src/services/worktree/runaway-file-watcher.js +251 -0
  117. package/dist/backend/backend/src/services/worktree/runaway-file-watcher.js.map +1 -0
  118. package/dist/backend/backend/src/services/worktree/worktree-janitor.service.d.ts +30 -0
  119. package/dist/backend/backend/src/services/worktree/worktree-janitor.service.d.ts.map +1 -1
  120. package/dist/backend/backend/src/services/worktree/worktree-janitor.service.js +82 -0
  121. package/dist/backend/backend/src/services/worktree/worktree-janitor.service.js.map +1 -1
  122. package/dist/backend/backend/src/utils/screen-excerpt.d.ts +34 -0
  123. package/dist/backend/backend/src/utils/screen-excerpt.d.ts.map +1 -0
  124. package/dist/backend/backend/src/utils/screen-excerpt.js +56 -0
  125. package/dist/backend/backend/src/utils/screen-excerpt.js.map +1 -0
  126. package/dist/backend/build-info.json +2 -2
  127. package/dist/cli/backend/src/constants.d.ts +39 -0
  128. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  129. package/dist/cli/backend/src/constants.js +39 -0
  130. package/dist/cli/backend/src/constants.js.map +1 -1
  131. package/dist/cli/backend/src/services/agent/agent-registration.service.d.ts.map +1 -1
  132. package/dist/cli/backend/src/services/agent/agent-registration.service.js +4 -2
  133. package/dist/cli/backend/src/services/agent/agent-registration.service.js.map +1 -1
  134. package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.js +1 -1
  135. package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.js.map +1 -1
  136. package/dist/cli/backend/src/services/messaging/sub-agent-message-queue.service.d.ts +18 -4
  137. package/dist/cli/backend/src/services/messaging/sub-agent-message-queue.service.d.ts.map +1 -1
  138. package/dist/cli/backend/src/services/messaging/sub-agent-message-queue.service.js +36 -8
  139. package/dist/cli/backend/src/services/messaging/sub-agent-message-queue.service.js.map +1 -1
  140. package/dist/cli/backend/src/services/session/session-command-helper.d.ts +17 -0
  141. package/dist/cli/backend/src/services/session/session-command-helper.d.ts.map +1 -1
  142. package/dist/cli/backend/src/services/session/session-command-helper.js +32 -0
  143. package/dist/cli/backend/src/services/session/session-command-helper.js.map +1 -1
  144. package/dist/cli/backend/src/services/task-pool/pool-storage.d.ts +9 -1
  145. package/dist/cli/backend/src/services/task-pool/pool-storage.d.ts.map +1 -1
  146. package/dist/cli/backend/src/services/task-pool/pool-storage.js +34 -3
  147. package/dist/cli/backend/src/services/task-pool/pool-storage.js.map +1 -1
  148. package/dist/cli/backend/src/utils/screen-excerpt.d.ts +34 -0
  149. package/dist/cli/backend/src/utils/screen-excerpt.d.ts.map +1 -0
  150. package/dist/cli/backend/src/utils/screen-excerpt.js +56 -0
  151. package/dist/cli/backend/src/utils/screen-excerpt.js.map +1 -0
  152. package/package.json +1 -1
@@ -222,6 +222,12 @@ api_call() {
222
222
  local url="${CREWLY_API_URL}/api${endpoint}"
223
223
  _prune_skill_output
224
224
  local args=(-s -w '\n%{http_code}' -X "$method")
225
+ # Optional ceiling on the whole request (seconds). A caller that can tell
226
+ # "the server is slow" from "it failed" (poll-tasks' claim) sets it; curl
227
+ # then exits 28 instead of hanging until the skill runner kills the call.
228
+ if [ -n "${CREWLY_API_MAX_TIME:-}" ]; then
229
+ args+=(--max-time "$CREWLY_API_MAX_TIME")
230
+ fi
225
231
  # A raw file upload (app-data --upload): CREWLY_API_UPLOAD_FILE is sent as the
226
232
  # body, byte for byte, with CREWLY_API_UPLOAD_TYPE as its Content-Type and the
227
233
  # URI-encoded CREWLY_API_UPLOAD_NAME as X-File-Name. Everything else is JSON.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: App Data
3
3
  description: Read and write the data of a Crewly App you published (the same collections the app's page sees through crewly.db) — list, get, set, update, add, delete. Use it to fill an app with content, act on what the owner entered, or answer an [APP CHANGES] message.
4
- version: 1.5.0
4
+ version: 1.6.0
5
5
  category: productivity
6
6
  skillType: claude-skill
7
7
  assignableRoles:
@@ -44,6 +44,7 @@ bash execute.sh --app 28au74d9cj --set meta settings --data '{"title":"Weekly sh
44
44
  bash execute.sh --app 28au74d9cj --update items 1kx2-ab --data '{"done":true}' --if-rev 3
45
45
  bash execute.sh --app 28au74d9cj --add items --data '{"name":"eggs","done":false}'
46
46
  bash execute.sh --app 28au74d9cj --delete items 1kx2-ab
47
+ bash execute.sh --app 28au74d9cj --get-field items 1kx2-ab --field /photo --out ./photo.png --decode # full value of a cut field
47
48
  bash execute.sh --app 28au74d9cj --request-access --reason "write the morning briefing" # ask the owner, see "Collaborators"
48
49
  bash execute.sh --app 28au74d9cj --collaborators # who the owner let in
49
50
  bash execute.sh --app 28au74d9cj --owner # who the app's comments go to
@@ -200,3 +201,14 @@ write private information there.
200
201
 
201
202
  Collection names: `[A-Za-z0-9_-]{1,64}`. Doc ids: `[A-Za-z0-9_.:-]{1,128}`.
202
203
  Documents are JSON objects up to 256 KB.
204
+
205
+ ## Long values (over 65,536 characters)
206
+
207
+ `--get` and `--list` cut any string over 65,536 characters (a base64 photo, a
208
+ long document) so it cannot flood your terminal. A cut is never silent: the
209
+ answer carries `truncated: [{"field":"/photo","chars":123456}]` (in a list,
210
+ each entry also has the doc `id`). Do not parse a flagged value as if it were
211
+ whole. Fetch it with `--get-field <collection> <docId> --field <pointer> --out
212
+ <file>`: the full value goes to a file inside your project (nothing is printed
213
+ but `{file, bytes}`). Add `--decode` for base64 / data-URL values to get the
214
+ real binary (the image itself).
@@ -15,6 +15,7 @@ print_usage() {
15
15
  Usage:
16
16
  bash execute.sh --app <appId> --list <collection> [--limit 100] [--after <docId>]
17
17
  bash execute.sh --app <appId> --get <collection> <docId>
18
+ bash execute.sh --app <appId> --get-field <collection> <docId> --field </json/pointer> --out <file> [--decode]
18
19
  bash execute.sh --app <appId> --set <collection> <docId> --data '{"done":true}'
19
20
  bash execute.sh --app <appId> --update <collection> <docId> --data '{"done":true}' [--if-rev 4]
20
21
  bash execute.sh --app <appId> --add <collection> --data '{"name":"milk"}'
@@ -32,6 +33,10 @@ Options:
32
33
  --if-rev Only update when the doc is still at this rev (409 conflict otherwise)
33
34
  --limit Page size for --list (1-500, default 100)
34
35
  --after Continue a --list after this doc id (the previous page's "next")
36
+ --get-field Save the FULL value of one field to a file (reads cut strings over 65,536 characters and list them in "truncated"); prints only {file, bytes}
37
+ --field JSON pointer into the doc's data, as listed in "truncated" (e.g. /photo, /items/0/image)
38
+ --out File to write for --get-field: inside your project directory, not an existing symlink
39
+ --decode With --get-field: base64-decode the value (a data:...;base64, prefix is dropped) so a photo is written as the real image
35
40
  --upload Put a file (an image for a post) into the app and print its url; works for the owner's agents and for collaborators. Regular, non-symlink file inside your project directory, up to 10 MB (the app's own limit may be smaller)
36
41
  --name File name to show for --upload (default: the file's name)
37
42
  --request-access Ask the owner to let your team (default) or only you work in an app another team published; a card goes to the owner, nothing is granted until they tap Allow
@@ -51,10 +56,20 @@ fail_from() {
51
56
  exit 1
52
57
  }
53
58
 
59
+ # Run one API call. stdout (the whole response body, however many lines it
60
+ # spans) is kept apart from stderr (the missing-session warning, or the JSON
61
+ # error object api_call prints on failure) so neither pollutes the other.
54
62
  call() {
55
- local out
56
- out=$(api_call "$@" 2>&1) || { fail_from "$(printf '%s\n' "$out" | tail -n 1)"; }
57
- printf '%s\n' "$out" | tail -n 1
63
+ local out err errfile rc=0
64
+ errfile=$(mktemp "${TMPDIR:-/tmp}/app-data-err.XXXXXX") || errfile=/dev/null
65
+ out=$(api_call "$@" 2>"$errfile") || rc=$?
66
+ err=$(cat "$errfile" 2>/dev/null || true)
67
+ [ "$errfile" = /dev/null ] || rm -f "$errfile"
68
+ if [ "$rc" -ne 0 ]; then
69
+ # The error object is the last non-empty stderr line (warnings come first).
70
+ fail_from "$(printf '%s\n' "$err" | awk 'NF{l=$0} END{print l}')"
71
+ fi
72
+ printf '%s\n' "$out"
58
73
  }
59
74
 
60
75
  uri() { jq -rn --arg v "$1" '$v|@uri'; }
@@ -107,13 +122,35 @@ content_type_for() {
107
122
  esac
108
123
  }
109
124
 
110
- APP=""; OP=""; UPLOAD=""; NAME=""; COLL=""; DOC=""; DATA=""; IF_REV=""; LIMIT=""; AFTER=""; SCOPE=""; REASON=""; SET_OWNER=""; WHO=""
125
+ # --out for --get-field: a new regular file inside the project directory
126
+ # (CREWLY_PROJECT_PATH, else the working directory), never under Crewly's home,
127
+ # never through a symlink. Prints the absolute target path.
128
+ resolve_out_path() {
129
+ local given="$1" dir base project real_dir
130
+ dir="$(dirname "$given")"; base="$(basename "$given")"
131
+ [ -d "$dir" ] || error_exit "the directory of --out does not exist: $dir"
132
+ real_dir="$(cd "$dir" && pwd -P)"
133
+ project="$(cd "${CREWLY_PROJECT_PATH:-$PWD}" && pwd -P)" || error_exit "cannot resolve the project directory"
134
+ case "$real_dir/" in "$project"/*) ;; *) error_exit "--out must be inside your project directory ($project)" ;; esac
135
+ case "$real_dir/" in "${CREWLY_HOME:-$HOME/.crewly}"/*|"$HOME/.crewly"/*) error_exit "--out is inside Crewly's home directory" ;; esac
136
+ [ ! -L "$real_dir/$base" ] || error_exit "--out is a symbolic link; pass a real file path"
137
+ [ ! -d "$real_dir/$base" ] || error_exit "--out is a directory"
138
+ printf '%s/%s' "$real_dir" "$base"
139
+ }
140
+
141
+ APP=""; OP=""; UPLOAD=""; NAME=""; COLL=""; DOC=""; DATA=""; IF_REV=""; LIMIT=""; AFTER=""; SCOPE=""; REASON=""; SET_OWNER=""; WHO=""; FIELD=""; OUT_FILE=""; DECODE=""
111
142
  while [[ $# -gt 0 ]]; do
112
143
  case "$1" in
113
144
  --app) [ $# -ge 2 ] || error_exit "--app requires a value"; APP="$2"; shift 2 ;;
114
145
  --list|--add)
115
146
  [ $# -ge 2 ] || error_exit "$1 requires a collection"
116
147
  OP="${1#--}"; COLL="$2"; shift 2 ;;
148
+ --get-field)
149
+ [ $# -ge 3 ] || error_exit "--get-field requires a collection and a doc id"
150
+ OP="get-field"; COLL="$2"; DOC="$3"; shift 3 ;;
151
+ --field) [ $# -ge 2 ] || error_exit "--field requires a JSON pointer"; FIELD="$2"; shift 2 ;;
152
+ --out) [ $# -ge 2 ] || error_exit "--out requires a file path"; OUT_FILE="$2"; shift 2 ;;
153
+ --decode) DECODE=1; shift ;;
117
154
  --get|--set|--update|--delete)
118
155
  [ $# -ge 3 ] || error_exit "$1 requires a collection and a doc id"
119
156
  OP="${1#--}"; COLL="$2"; DOC="$3"; shift 3 ;;
@@ -191,11 +228,28 @@ case "$OP" in
191
228
  [ -n "$LIMIT" ] && Q="limit=$(uri "$LIMIT")"
192
229
  [ -n "$AFTER" ] && Q="${Q:+$Q&}after=$(uri "$AFTER")"
193
230
  RESPONSE=$(call GET "${BASE}${Q:+?$Q}") || { printf '%s\n' "$RESPONSE"; exit 1; }
194
- printf '%s' "$RESPONSE" | jq -c '{success: true, docs: [.data.docs[]? | {id, data, rev, updatedAt, updatedBy: (.updatedBy.kind // null)} + (if .updatedBy.kind == "agent" then {by: .updatedBy.id} else {} end)], next: .data.next}'
231
+ printf '%s' "$RESPONSE" | jq -c '{success: true, docs: [.data.docs[]? | {id, data, rev, updatedAt, updatedBy: (.updatedBy.kind // null)} + (if .updatedBy.kind == "agent" then {by: .updatedBy.id} else {} end)], next: .data.next} + (if .data.truncated then {truncated: .data.truncated, truncatedHint: .data.truncatedHint} else {} end)'
232
+ ;;
233
+ get-field)
234
+ [ -n "$FIELD" ] || error_exit "--field is required for --get-field (a JSON pointer such as /photo)"
235
+ [ -n "$OUT_FILE" ] || error_exit "--out is required for --get-field"
236
+ TARGET="$(resolve_out_path "$OUT_FILE")" || exit 1
237
+ RESPONSE=$(CREWLY_SKILL_FULL_OUTPUT=1 call GET "${BASE}/$(uri "$DOC")/field?path=$(uri "$FIELD")") || { printf '%s\n' "$RESPONSE"; exit 1; }
238
+ printf '%s' "$RESPONSE" | jq -e '.data.type == "string"' >/dev/null 2>&1 || error_exit "that field is not a string; read it with --get"
239
+ TMP_OUT="$(mktemp "${TARGET}.XXXXXX")" || error_exit "cannot write next to $OUT_FILE"
240
+ printf '%s' "$RESPONSE" | jq -j '.data.value' > "$TMP_OUT"
241
+ if [ -n "$DECODE" ]; then
242
+ sed -E '1s#^data:[^,]*;base64,##' "$TMP_OUT" | tr -d '\n\r ' > "${TMP_OUT}.b64"
243
+ { base64 -d < "${TMP_OUT}.b64" > "${TMP_OUT}.bin" 2>/dev/null || base64 -D < "${TMP_OUT}.b64" > "${TMP_OUT}.bin" 2>/dev/null; } \
244
+ || { rm -f "$TMP_OUT" "${TMP_OUT}.b64" "${TMP_OUT}.bin"; error_exit "the value is not valid base64"; }
245
+ mv "${TMP_OUT}.bin" "$TMP_OUT"; rm -f "${TMP_OUT}.b64"
246
+ fi
247
+ mv "$TMP_OUT" "$TARGET"
248
+ jq -cn --arg f "$TARGET" --argjson b "$(wc -c < "$TARGET" | tr -d ' ')" --argjson c "$(printf '%s' "$RESPONSE" | jq '.data.chars')" '{success: true, file: $f, bytes: $b, chars: $c}'
195
249
  ;;
196
250
  get)
197
251
  RESPONSE=$(call GET "${BASE}/$(uri "$DOC")") || { printf '%s\n' "$RESPONSE"; exit 1; }
198
- printf '%s' "$RESPONSE" | jq -c '{success: true, id: .data.id, data: .data.data, rev: .data.rev, updatedAt: .data.updatedAt}'
252
+ printf '%s' "$RESPONSE" | jq -c '{success: true, id: .data.id, data: .data.data, rev: .data.rev, updatedAt: .data.updatedAt} + (if .data.truncated then {truncated: .data.truncated, truncatedHint: .data.truncatedHint} else {} end)'
199
253
  ;;
200
254
  set)
201
255
  need_data
@@ -24,6 +24,18 @@ class H(BaseHTTPRequestHandler):
24
24
  def send(code, obj):
25
25
  self.send_response(code); self.send_header('Content-Type', 'application/json'); self.end_headers()
26
26
  self.wfile.write(json.dumps(obj).encode())
27
+ if self.path.endswith('/pretty'):
28
+ self.send_response(200); self.send_header('Content-Type', 'application/json'); self.end_headers()
29
+ return self.wfile.write(json.dumps({'success': True, 'data': {'id': 'pretty', 'data': {'text': 'line1\nline2'}, 'rev': 3, 'updatedAt': 't'}}, indent=2).encode())
30
+ if '/field' in self.path:
31
+ from urllib.parse import urlparse, parse_qs
32
+ q = parse_qs(urlparse(self.path).query).get('path', [''])[0]
33
+ if q == '/photo':
34
+ return send(200, {'success': True, 'data': {'field': q, 'type': 'string', 'chars': 38, 'value': 'data:image/png;base64,aGVsbG8gd29ybGQ='}})
35
+ if q == '/n':
36
+ return send(200, {'success': True, 'data': {'field': q, 'type': 'number', 'value': 1}})
37
+ if self.path.endswith('/trunc'):
38
+ return send(200, {'success': True, 'data': {'id': 'trunc', 'data': {'photo': 'xx'}, 'rev': 1, 'updatedAt': 't', 'truncated': [{'field': '/photo', 'chars': 70000}], 'truncatedHint': 'cut'}})
27
39
  if self.path.endswith('/missing'):
28
40
  return send(404, {'success': False, 'error': 'not_found', 'message': 'Document not found.', 'hint': 'No such app, document or version for this account.'})
29
41
  if self.path.endswith('/collaborators') and method == 'GET':
@@ -89,6 +101,15 @@ check "list: path" "$(jq -r .path "$STUB_LOG")" "/api/apps/$A/data/items?limit=5
89
101
  OUT=$(run --app $A --get items milk)
90
102
  check "get" "$OUT" '{"success":true,"id":"milk","data":{"name":"milk","done":false},"rev":2,"updatedAt":"t"}'
91
103
 
104
+ # A multi-line (pretty-printed) response body must survive whole (#1122).
105
+ OUT=$(run --app $A --get items pretty)
106
+ check "get: multi-line body kept whole" "$OUT" '{"success":true,"id":"pretty","data":{"text":"line1\nline2"},"rev":3,"updatedAt":"t"}'
107
+ # The missing-session warning goes to stderr and must not break the JSON (#1122).
108
+ OUT=$(env "${ENVS[@]}" CREWLY_SESSION_NAME= bash "$EXEC" --app $A --get items pretty 2>/dev/null)
109
+ check "get: stderr warning does not pollute stdout" "$(printf '%s' "$OUT" | jq -r .id)" "pretty"
110
+ OUT=$(env "${ENVS[@]}" CREWLY_SESSION_NAME= bash "$EXEC" --app $A --get items missing 2>/dev/null; true)
111
+ check "error with warning: reason still parsed" "$(printf '%s' "$OUT" | jq -r .reason)" "not_found"
112
+
92
113
  OUT=$(run --app $A --get items missing; true)
93
114
  check "get missing → not_found" "$(printf '%s' "$OUT" | jq -c '{success, status, reason}')" '{"success":false,"status":404,"reason":"not_found"}'
94
115
 
@@ -195,5 +216,26 @@ check "upload: 2xx without a url fails, not success with nulls" "$(printf '%s' "
195
216
  OUT=$(run_err --app $A --upload || true)
196
217
  check "upload needs a path" "$(printf '%s' "$OUT" | grep -c 'requires a file path')" "1"
197
218
 
219
+ # --get / --list carry the truncation flag through (#1145).
220
+ OUT=$(run --app $A --get items trunc)
221
+ check "get: truncation flag passed through" "$(printf '%s' "$OUT" | jq -c '.truncated')" '[{"field":"/photo","chars":70000}]'
222
+
223
+ # --get-field --out writes the full value to a file and prints no content (#1145).
224
+ OUT=$(run --app $A --get-field items milk --field /photo --out "$WORK/proj/photo.txt")
225
+ check "get-field: path" "$(jq -r .path "$STUB_LOG")" "/api/apps/$A/data/items/milk/field?path=%2Fphoto"
226
+ check "get-field: file holds the raw value" "$(cat "$WORK/proj/photo.txt")" 'data:image/png;base64,aGVsbG8gd29ybGQ='
227
+ check "get-field: prints a receipt, not the value" "$(printf '%s' "$OUT" | jq -c '{success, bytes, chars}')" '{"success":true,"bytes":38,"chars":38}'
228
+ run --app $A --get-field items milk --field /photo --out "$WORK/proj/photo.bin" --decode >/dev/null
229
+ check "get-field --decode: data URL decoded to bytes" "$(cat "$WORK/proj/photo.bin")" 'hello world'
230
+ OUT=$(run_err --app $A --get-field items milk --field /photo --out "$WORK/outside-photo.txt" || true)
231
+ check "get-field: --out outside the project refused" "$(printf '%s' "$OUT" | grep -c 'inside your project')" "1"
232
+ ln -s "$WORK/outside.json" "$WORK/proj/out-link.txt"
233
+ OUT=$(run_err --app $A --get-field items milk --field /photo --out "$WORK/proj/out-link.txt" || true)
234
+ check "get-field: symlink --out refused" "$(printf '%s' "$OUT" | grep -c 'symbolic link')" "1"
235
+ OUT=$(run_err --app $A --get-field items milk --field /n --out "$WORK/proj/n.txt" || true)
236
+ check "get-field: non-string field refused" "$(printf '%s' "$OUT" | grep -c 'not a string')" "1"
237
+ OUT=$(run_err --app $A --get-field items milk --out "$WORK/proj/x.txt" || true)
238
+ check "get-field: --field required" "$(printf '%s' "$OUT" | grep -c -- '--field is required')" "1"
239
+
198
240
  echo "app-data: $PASS passed, $FAIL failed"
199
241
  [ "$FAIL" -eq 0 ]
@@ -60,9 +60,11 @@ On success the skill prints **one JSON line** that always names the `workItemId`
60
60
  Resolution order:
61
61
 
62
62
  1. `workItemId`, if you pass it — always wins.
63
- 2. Otherwise, a lookup of the WorkItem currently `running` against your
63
+ 2. Otherwise, ONLY when you named no task (no `workItemId`, no `taskId`): a lookup of the WorkItem currently `running` against your
64
64
  `sessionName` (`GET /api/task-pool/items?status=running&target=<session>`).
65
- 3. If neither resolves, the skill exits non-zero with an error naming the
65
+ 3. If you passed `taskId` without `workItemId`, the skill
66
+ refuses (non-zero exit): it never substitutes your running item for an id you named.
67
+ 4. If nothing resolves, the skill exits non-zero with an error naming the
66
68
  session it searched. It never reports success without completing something.
67
69
 
68
70
  Pass `workItemId` whenever you know it — the lookup in step 2 depends on the
@@ -124,6 +124,13 @@ fi
124
124
  #
125
125
  # WORK_ITEM_ID was read from the input up top alongside the other params,
126
126
  # so the identifier is known before any of the legacy path handling runs.
127
+ # A `taskId` names a specific task: never swap it for whatever happens to be
128
+ # running (#1134 - a complete-task naming one item closed the agent's active,
129
+ # unfinished one). The legacy `absoluteTaskPath` is only logging context and
130
+ # keeps its documented pool lookup.
131
+ if [ -z "$WORK_ITEM_ID" ] && [ -n "$TASK_ID" ]; then
132
+ error_exit "Nothing was completed: you passed taskId '${TASK_ID}' but no 'workItemId', and this skill does not guess which WorkItem you meant. Pass 'workItemId' (find it with GET /api/task-pool/items?target=${SESSION_NAME}) and call again."
133
+ fi
127
134
  if [ -z "$WORK_ITEM_ID" ]; then
128
135
  POOL_RESP=$(api_call_full GET "/task-pool/items?status=running&target=${SESSION_NAME}" 2>/dev/null || echo '{}')
129
136
  WORK_ITEM_ID=$(echo "$POOL_RESP" | jq -r '.workItems[0].id // .data[0].id // empty' 2>/dev/null || true)
@@ -0,0 +1,56 @@
1
+ #!/bin/bash
2
+ # Tests for complete-task never swapping a named id for the running item (#1134) — run with: bash explicit-id.test.sh
3
+ set -u
4
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+ EXEC="$HERE/execute.sh"
6
+ PASS=0; FAIL=0
7
+ check() {
8
+ if [ "$2" = "$3" ]; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); echo "FAIL: $1"; echo " want: $3"; echo " got: $2"; fi
9
+ }
10
+
11
+ PORT=18854
12
+ STUB_LOG="$(mktemp)"; export STUB_LOG; export STUB_PORT="$PORT"
13
+ : > "$STUB_LOG"
14
+ python3 -u <<'PY' >/dev/null 2>&1 &
15
+ from http.server import BaseHTTPRequestHandler, HTTPServer
16
+ import json, os
17
+ LOG = os.environ['STUB_LOG']; PORT = int(os.environ['STUB_PORT'])
18
+ class H(BaseHTTPRequestHandler):
19
+ def reply(self, code, obj):
20
+ self.send_response(code); self.send_header('Content-Type', 'application/json'); self.end_headers()
21
+ self.wfile.write(json.dumps(obj).encode())
22
+ def note(self):
23
+ open(LOG, 'a').write(self.command + ' ' + self.path + '\n')
24
+ def do_GET(self):
25
+ self.note()
26
+ return self.reply(200, {'workItems': [{'id': 'wi-running'}]})
27
+ def do_POST(self):
28
+ n = int(self.headers.get('content-length', '0')); self.rfile.read(n)
29
+ self.note()
30
+ return self.reply(200, {'success': True})
31
+ def log_message(self, *a, **k): pass
32
+ HTTPServer(('127.0.0.1', PORT), H).serve_forever()
33
+ PY
34
+ STUB_PID=$!
35
+ disown "$STUB_PID" 2>/dev/null || true
36
+ trap 'kill "$STUB_PID" >/dev/null 2>&1; rm -f "$STUB_LOG"' EXIT
37
+ for i in $(seq 1 30); do curl -s -o /dev/null -m 0.2 "http://127.0.0.1:${PORT}/" 2>/dev/null && break; sleep 0.1; done
38
+
39
+ run() { CREWLY_SESSION_NAME=dev-1 CREWLY_API_URL="http://127.0.0.1:${PORT}" bash "$EXEC" "$1" 2>&1; echo "exit=$?"; }
40
+
41
+ : > "$STUB_LOG"
42
+ OUT=$(run '{"taskId":"wi-other","sessionName":"dev-1","summary":"done"}')
43
+ check "taskId without workItemId: refused" "$(printf '%s' "$OUT" | grep -c 'exit=1')" "1"
44
+ check "taskId without workItemId: names the problem" "$(printf '%s' "$OUT" | grep -c "does not guess")" "1"
45
+ check "taskId without workItemId: nothing sent" "$(grep -vc heartbeat "$STUB_LOG")" "0"
46
+
47
+ : > "$STUB_LOG"
48
+ run '{"workItemId":"wi-given","sessionName":"dev-1","summary":"done"}' >/dev/null
49
+ check "explicit workItemId: completes exactly that one" "$(grep -v heartbeat "$STUB_LOG")" "POST /api/task-pool/complete/wi-given"
50
+
51
+ : > "$STUB_LOG"
52
+ run '{"sessionName":"dev-1","summary":"done"}' >/dev/null
53
+ check "no id at all: running-item lookup still works" "$(tail -1 "$STUB_LOG")" "POST /api/task-pool/complete/wi-running"
54
+
55
+ echo "complete-task: $PASS passed, $FAIL failed"
56
+ [ "$FAIL" -eq 0 ]
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: Docs Comment
3
- description: Handle feedback left as comments in a Google Doc — list comments and replies, reply, resolve, or add a comment (via the Google Workspace grant held by Crewly Cloud). Writes as the owner's Google account — say what you posted.
4
- version: 1.0.0
3
+ description: Handle feedback left as comments in a Google Doc — list comments and replies; reply, resolve and add are queued for the owner's OK and posted as a batch only after they reply 「发 C<n>」 (via the Google Workspace grant held by Crewly Cloud).
4
+ version: 1.1.0
5
5
  category: productivity
6
6
  skillType: claude-skill
7
7
  assignableRoles:
@@ -41,6 +41,9 @@ bash execute.sh reply --doc 1AbC… --comment AAAB… --text "Updated the intr
41
41
  bash execute.sh resolve --doc 1AbC… --comment AAAB… --text "Done in v2."
42
42
  bash execute.sh resolve --doc 1AbC… --comment AAAB…
43
43
  bash execute.sh add --doc 1AbC… --text "Is this number from Q2?" --quote "grew 40%"
44
+ bash execute.sh pending # what is queued and waiting on the owner
45
+ bash execute.sh send --batch C3 # post a batch AFTER the owner said 「发 C3」
46
+ bash execute.sh discard --batch C3
44
47
  ```
45
48
 
46
49
  `--doc` takes a document id or its docs.google.com URL. `--account` picks a
@@ -56,8 +59,45 @@ connected Google account (see below).
56
59
  with a question if it is unclear. Do not resolve a comment you did not
57
60
  address.
58
61
 
59
- Everything you post appears under the owner's Google name. Tell the owner
60
- what you replied to or resolved.
62
+ ## Posting needs the owner's OK (one OK per batch)
63
+
64
+ Comments and replies on a Google Doc are visible to every collaborator under
65
+ the owner's name, so `reply`, `resolve` and `add` are outbound actions like
66
+ `gmail-send`: **they do not post.** Each call queues the comment and returns
67
+ a batch code:
68
+
69
+ ```json
70
+ {"success":true,"held":true,"posted":false,"batch":"C3","queued":38,"message":"Queued as C3 …"}
71
+ ```
72
+
73
+ All your queued comments share one batch, so work through the whole document
74
+ first, then ask the owner ONCE, in plain words, with what will go out:
75
+
76
+ > "I have 38 replies ready on the Q3 plan doc (mostly "done, see para 2",
77
+ > 4 questions). Reply 「发 C3」 to post them all."
78
+
79
+ The owner answers from their phone with one message, `发 C3` (or `send C3`).
80
+ Only that genuine owner message counts — not a suggestion, a pre-filled line,
81
+ or your own paraphrase. When the `[SLACK…]`/`[CHAT:…]` message arrives, run:
82
+
83
+ ```bash
84
+ bash execute.sh send --batch C3
85
+ ```
86
+
87
+ Rules:
88
+
89
+ - The OK must come AFTER your last queued comment. If you queue one more
90
+ after asking, the earlier OK no longer covers the batch: ask again.
91
+ - `send` posts in order and stops on the first failure; whatever is not
92
+ posted stays queued, so running `send` again never double-posts.
93
+ - A refusal (`needs_owner_confirmation`) means the owner has not said it
94
+ yet. Do not look for another way to post.
95
+ - If the owner wants changes, `discard --batch C3` and queue fresh ones.
96
+ - Queued comments live in this Crewly process; if it restarts before the OK,
97
+ queue them again.
98
+ - `list` is read-only and always open.
99
+
100
+ Tell the owner what you posted once `send` succeeds.
61
101
 
62
102
  ## Output
63
103
 
@@ -71,6 +111,11 @@ Resolved comments are left out unless you pass `--include-resolved`.
71
111
  `truncated: true` means the document has more than 500 comments and only the
72
112
  first 500 were read.
73
113
 
114
+ `reply` / `resolve` / `add` queue the comment (see above) until `send --batch`
115
+ succeeds. A successful `send` returns `{"success":true,"batch":"C3","posted":38,"remaining":0,"results":[…]}`;
116
+ on a mid-batch failure `success` is false with `error: "partial"` and the number still queued.
117
+ When the batch posts, each result has the shape below.
118
+
74
119
  `reply` / `resolve`:
75
120
 
76
121
  ```json
@@ -13,6 +13,9 @@
13
13
  # bash execute.sh reply --doc <id|url> --comment <id> --text "…"
14
14
  # bash execute.sh resolve --doc <id|url> --comment <id> [--text "…"]
15
15
  # bash execute.sh add --doc <id|url> --text "…" [--quote "exact text"]
16
+ # bash execute.sh pending (queued comments awaiting the owner)
17
+ # bash execute.sh send --batch C3 (post a batch the owner approved)
18
+ # bash execute.sh discard --batch C3
16
19
  # bash execute.sh '{"command":"list","doc":"<id>"}'
17
20
  # =============================================================================
18
21
  set -euo pipefail
@@ -26,6 +29,9 @@ Usage:
26
29
  bash execute.sh reply --doc <id|url> --comment <id> --text "…"
27
30
  bash execute.sh resolve --doc <id|url> --comment <id> [--text "…"]
28
31
  bash execute.sh add --doc <id|url> --text "…" [--quote "exact text in the doc"]
32
+ bash execute.sh pending
33
+ bash execute.sh send --batch C3
34
+ bash execute.sh discard --batch C3
29
35
  bash execute.sh '{"command":"reply","doc":"<id>","comment":"<id>","text":"…"}'
30
36
 
31
37
  Options:
@@ -35,6 +41,10 @@ Options:
35
41
  --quote Exact document text the new comment is about (add only;
36
42
  Google Docs may still show it as an unanchored comment)
37
43
  --include-resolved list: include resolved comments too
44
+ --batch send / discard: the batch code a reply / resolve / add returned (e.g. C3)
45
+
46
+ reply, resolve and add do NOT post: they queue the comment for the owner and
47
+ return a batch code. After the owner answers 「发 <code>」, run send --batch.
38
48
  --account Which connected Google account to act as (default: your primary)
39
49
  --help | -h Show this help
40
50
  EOF_USAGE
@@ -66,7 +76,7 @@ elif [[ $# -gt 0 && ${1:0:1} != '-' ]]; then
66
76
  COMMAND="$1"
67
77
  shift
68
78
  fi
69
- DOC=""; COMMENT=""; TEXT=""; TEXT_SET=0; QUOTE=""; INCLUDE_RESOLVED=0; ACCOUNT=""
79
+ DOC=""; COMMENT=""; TEXT=""; TEXT_SET=0; QUOTE=""; INCLUDE_RESOLVED=0; ACCOUNT=""; BATCH=""
70
80
  while [[ $# -gt 0 ]]; do
71
81
  case "$1" in
72
82
  --doc|--id) [ $# -ge 2 ] || error_exit "$1 requires a value"; DOC="$2"; shift 2 ;;
@@ -75,6 +85,7 @@ while [[ $# -gt 0 ]]; do
75
85
  --quote) [ $# -ge 2 ] || error_exit "--quote requires a value"; QUOTE="$2"; shift 2 ;;
76
86
  --include-resolved) INCLUDE_RESOLVED=1; shift ;;
77
87
  --account) [ $# -ge 2 ] || error_exit "--account requires a value"; ACCOUNT="$2"; shift 2 ;;
88
+ --batch) [ $# -ge 2 ] || error_exit "--batch requires a value"; BATCH="$2"; shift 2 ;;
78
89
  --help|-h) print_usage; exit 0 ;;
79
90
  *) error_exit "Unknown option: $1" ;;
80
91
  esac
@@ -88,13 +99,35 @@ if [ -n "$INPUT_JSON" ]; then
88
99
  [ -z "$QUOTE" ] && QUOTE=$(printf '%s' "$INPUT" | jq -r '.quote // empty')
89
100
  [ "$INCLUDE_RESOLVED" -eq 0 ] && [ "$(printf '%s' "$INPUT" | jq -r '.includeResolved // false')" = "true" ] && INCLUDE_RESOLVED=1
90
101
  [ -z "$ACCOUNT" ] && ACCOUNT=$(printf '%s' "$INPUT" | jq -r '.account // empty')
102
+ [ -z "$BATCH" ] && BATCH=$(printf '%s' "$INPUT" | jq -r '.batch // empty')
91
103
  fi
92
104
  # Route the call at one connected Google account; unset means the default.
93
105
  [ -n "$ACCOUNT" ] && export CREWLY_GOOGLE_ACCOUNT="$ACCOUNT"
94
106
  # Accept a full docs.google.com URL too.
95
107
  DOC=$(printf '%s' "$DOC" | sed -E 's#.*/document/d/([^/?]+).*#\1#')
96
108
 
97
- [ -n "$COMMAND" ] || error_exit "a command is required: list, reply, resolve or add (see --help)"
109
+ [ -n "$COMMAND" ] || error_exit "a command is required: list, reply, resolve, add, pending, send or discard (see --help)"
110
+ BATCHES="/google/doc-comment-batches"
111
+ # Comment writes are outbound: an agent's are queued, not posted (HTTP 202).
112
+ # Print the queue answer instead of a post result.
113
+ held_or() {
114
+ if printf '%s' "$1" | jq -e '.data.held == true' >/dev/null 2>&1; then
115
+ printf '%s' "$1" | jq -c '{success: true, held: true, posted: false, batch: .data.batch, queued: .data.queued, message: .data.message}'
116
+ return 0
117
+ fi
118
+ return 1
119
+ }
120
+ case "$COMMAND" in
121
+ pending)
122
+ RESPONSE=$(call GET "$BATCHES") || { printf '%s\n' "$RESPONSE"; exit 1; }
123
+ printf '%s' "$RESPONSE" | jq -c '{success: (.success // false), batches: [.data.batches[] | {batch: .code, agent: .agentSession, queued: (.items | length), items: [.items[] | {action, docId, commentId, text, quote}]}]}'
124
+ exit 0 ;;
125
+ send|discard)
126
+ require_param "batch (--batch)" "$BATCH"
127
+ RESPONSE=$(call POST "${BATCHES}/$(uri "$BATCH")/${COMMAND}" '{}') || { printf '%s\n' "$RESPONSE"; exit 1; }
128
+ printf '%s' "$RESPONSE" | jq -c '{success: (.success // false)} + .data'
129
+ exit 0 ;;
130
+ esac
98
131
  require_param "doc (--doc)" "$DOC"
99
132
  BASE="/google/docs/$(uri "$DOC")/comments"
100
133
 
@@ -111,18 +144,21 @@ case "$COMMAND" in
111
144
  require_param "text (--text)" "$TEXT"
112
145
  BODY=$(jq -cn --arg text "$TEXT" '{text: $text}')
113
146
  RESPONSE=$(call POST "${BASE}/$(uri "$COMMENT")/replies" "$BODY") || { printf '%s\n' "$RESPONSE"; exit 1; }
147
+ held_or "$RESPONSE" && exit 0
114
148
  printf '%s' "$RESPONSE" | jq -c '{success: (.success // false), action: "reply", docId: .data.docId, commentId: .data.commentId, replyId: .data.id, content: .data.content}'
115
149
  ;;
116
150
  resolve)
117
151
  require_param "comment (--comment)" "$COMMENT"
118
152
  BODY=$(jq -cn --arg text "$TEXT" 'if $text != "" then {text: $text} else {} end')
119
153
  RESPONSE=$(call POST "${BASE}/$(uri "$COMMENT")/resolve" "$BODY") || { printf '%s\n' "$RESPONSE"; exit 1; }
154
+ held_or "$RESPONSE" && exit 0
120
155
  printf '%s' "$RESPONSE" | jq -c '{success: (.success // false), action: "resolve", docId: .data.docId, commentId: .data.commentId, replyId: .data.id, content: (.data.content // ""), resolved: true}'
121
156
  ;;
122
157
  add)
123
158
  require_param "text (--text)" "$TEXT"
124
159
  BODY=$(jq -cn --arg text "$TEXT" --arg quote "$QUOTE" '{text: $text} + (if $quote != "" then {quote: $quote} else {} end)')
125
160
  RESPONSE=$(call POST "${BASE}" "$BODY") || { printf '%s\n' "$RESPONSE"; exit 1; }
161
+ held_or "$RESPONSE" && exit 0
126
162
  printf '%s' "$RESPONSE" | jq -c '{success: (.success // false), action: "add", docId: .data.docId, commentId: .data.id, content: .data.content} + (if .data.quote then {quote: .data.quote} else {} end)'
127
163
  ;;
128
164
  *)
@@ -30,6 +30,14 @@ class H(BaseHTTPRequestHandler):
30
30
  return self._send(403, {'success': False, 'error': 'reauth_required',
31
31
  'message': 'Replying to or adding comments on this document needs Google Drive edit access, which this Google account has not granted yet.',
32
32
  'hint': 'Ask the owner to reconnect Google Drive: run the google-connect skill with --product drive.'})
33
+ if '/docs/held/' in self.path:
34
+ return self._send(202, {'success': True, 'data': {'held': True, 'batch': 'C3', 'queued': 2, 'message': 'Queued as C3'}})
35
+ if self.path.startswith('/api/google/doc-comment-batches'):
36
+ if method == 'GET':
37
+ return self._send(200, {'success': True, 'data': {'batches': [{'code': 'C3', 'agentSession': 'ella', 'items': [{'action': 'reply', 'docId': 'd1', 'commentId': 'c1', 'text': 'Fixed'}]}]}})
38
+ if '/C9/' in self.path:
39
+ return self._send(403, {'success': False, 'error': 'needs_owner_confirmation', 'message': 'The owner has not confirmed batch C9'})
40
+ return self._send(200, {'success': True, 'data': {'batch': 'C3', 'posted': 2, 'remaining': 0}})
33
41
  if method == 'GET':
34
42
  return self._send(200, {'success': True, 'data': {'docId': 'd1', 'comments': [COMMENT], 'truncated': False}})
35
43
  if self.path.endswith('/replies'):
@@ -93,6 +101,26 @@ check "reauth: exit code" "$CODE" "1"
93
101
  check "reauth: reason" "$(printf '%s' "$OUT" | jq -r '.reason')" "reauth_required"
94
102
  check "reauth: hint names the one-tap path" "$(printf '%s' "$OUT" | jq -r '.hint' | grep -c 'google-connect skill with --product drive')" "1"
95
103
 
104
+ # a held comment is reported as queued, never as posted
105
+ for ACT in "reply --doc held --comment c1 --text x" "resolve --doc held --comment c1" "add --doc held --text x"; do
106
+ OUT=$(run $ACT)
107
+ check "held (${ACT%% *}): output" "$OUT" '{"success":true,"held":true,"posted":false,"batch":"C3","queued":2,"message":"Queued as C3"}'
108
+ done
109
+
110
+ # pending / send / discard
111
+ OUT=$(run pending)
112
+ check "pending: request" "$(req)" 'GET /api/google/doc-comment-batches null'
113
+ check "pending: output" "$OUT" '{"success":true,"batches":[{"batch":"C3","agent":"ella","queued":1,"items":[{"action":"reply","docId":"d1","commentId":"c1","text":"Fixed","quote":null}]}]}'
114
+ OUT=$(run send --batch C3)
115
+ check "send: request" "$(req)" 'POST /api/google/doc-comment-batches/C3/send {}'
116
+ check "send: output" "$OUT" '{"success":true,"batch":"C3","posted":2,"remaining":0}'
117
+ run discard --batch C3 >/dev/null
118
+ check "discard: request" "$(req)" 'POST /api/google/doc-comment-batches/C3/discard {}'
119
+ OUT=$(run send --batch C9); CODE=$?
120
+ check "send refused: exit code" "$CODE" "1"
121
+ check "send refused: reason" "$(printf '%s' "$OUT" | jq -r '.reason')" "needs_owner_confirmation"
122
+ check "send needs --batch" "$(run_err send | grep -c 'batch')" "1"
123
+
96
124
  # argument validation (no request sent)
97
125
  check "no command" "$(run_err --doc d1 | grep -c 'command is required')" "1"
98
126
  check "unknown command" "$(run_err delete --doc d1 | grep -c 'Unknown command')" "1"
@@ -19,6 +19,8 @@
19
19
  # Returns:
20
20
  # On claim: { success: true, claimed: true, workItem: {...}, claim: {...} }
21
21
  # No match: { success: true, claimed: false, available: 0, message: "..." }
22
+ # Pending: { success: true, claimed: false, claimPending: true, message: "..." } — the claim timed
23
+ # out and may still land; do not retry, check get-my-tasks later
22
24
  # Error: { error: "..." } on stderr, exit 1
23
25
  # =============================================================================
24
26
  set -euo pipefail
@@ -168,8 +170,18 @@ else
168
170
  }')
169
171
  fi
170
172
 
171
- CLAIM_RESPONSE=$(api_call POST "/task-pool/claim" "$CLAIM_BODY" 2>&1) || {
173
+ # The claim is a write the server may apply long after this call gives up
174
+ # (a slow pool-file rewrite queues behind other writes, crewly#1108). Bound
175
+ # the wait and report "pending" instead of failing: a failure invites a blind
176
+ # retry, which queues yet another write.
177
+ CLAIM_RESPONSE=$(CREWLY_API_MAX_TIME="${POLL_TASKS_CLAIM_MAX_TIME:-90}" api_call POST "/task-pool/claim" "$CLAIM_BODY" 2>&1) || {
172
178
  CLAIM_ERROR="$CLAIM_RESPONSE"
179
+ # Timed out (curl 28), no HTTP answer (status 0 / 000) or a gateway timeout:
180
+ # the claim may still land. Do not retry; look at get-my-tasks later.
181
+ if printf '%s' "$CLAIM_ERROR" | grep -Eq 'exit code 28|"status":(0|000|408|502|503|504)[,}]'; then
182
+ jq -n '{success: true, claimed: false, claimPending: true, message: "Claim pending: the server has not answered yet and may still apply it. Do NOT retry the claim; check get-my-tasks in a minute to see whether the work item is yours."}'
183
+ exit 0
184
+ fi
173
185
  # 404 = no available items (race condition — someone else claimed it)
174
186
  if printf '%s' "$CLAIM_ERROR" | grep -q '"status":404'; then
175
187
  jq -n '{success: true, claimed: false, available: 0, message: "No work items available (claimed by another agent)"}'
@@ -297,6 +297,43 @@ assert_json_field "fifo body has no workItemId" "$FIFO_BODY" ".workItemId" "null
297
297
  assert_json_field "fifo body types[0]" "$FIFO_BODY" ".filters.types[0]" "delegate"
298
298
  assert_json_field "fifo body types[1]" "$FIFO_BODY" ".filters.types[1]" "project_task"
299
299
 
300
+ # ---------------------------------------------------------------------------
301
+ # Test 9: a claim that times out is reported as pending, not as a failure
302
+ # (crewly#1108). Runs the real execute.sh against a stub curl: GETs answer
303
+ # with one queued item, the claim POST "times out" (curl exit 28).
304
+ # ---------------------------------------------------------------------------
305
+ echo "Test 9: claim timeout is reported as claimPending"
306
+
307
+ STUB_DIR=$(mktemp -d)
308
+ cat > "$STUB_DIR/curl" <<'STUB'
309
+ #!/bin/bash
310
+ method=GET
311
+ for a in "$@"; do
312
+ if [ "$prev" = "-X" ]; then method="$a"; fi
313
+ prev="$a"
314
+ done
315
+ if [ "$method" = "POST" ]; then
316
+ [ "$STUB_MODE" = "http504" ] && { printf '{"error":"gateway"}\n504'; exit 0; }
317
+ [ "$STUB_MODE" = "http500" ] && { printf '{"error":"boom"}\n500'; exit 0; }
318
+ exit 28
319
+ fi
320
+ printf '{"success":true,"count":1,"data":[{"id":"wi-1","title":"t","description":"d"}]}\n200'
321
+ STUB
322
+ chmod +x "$STUB_DIR/curl"
323
+
324
+ OUTPUT=$(PATH="$STUB_DIR:$PATH" STUB_MODE=timeout CREWLY_SESSION_NAME=agent-1 bash "$SCRIPT_DIR/execute.sh" '{"sessionName":"agent-1"}' </dev/null 2>/dev/null) && EXIT_CODE=$? || EXIT_CODE=$?
325
+ assert_eq "timeout exits 0" "0" "$EXIT_CODE"
326
+ assert_json_field "timeout: claimPending" "$OUTPUT" ".claimPending" "true"
327
+ assert_json_field "timeout: claimed false" "$OUTPUT" ".claimed" "false"
328
+ assert_contains "timeout: tells the agent not to retry" "Do NOT retry" "$OUTPUT"
329
+
330
+ OUTPUT=$(PATH="$STUB_DIR:$PATH" STUB_MODE=http504 CREWLY_SESSION_NAME=agent-1 bash "$SCRIPT_DIR/execute.sh" '{"sessionName":"agent-1"}' </dev/null 2>/dev/null) && EXIT_CODE=$? || EXIT_CODE=$?
331
+ assert_json_field "504: claimPending" "$OUTPUT" ".claimPending" "true"
332
+
333
+ OUTPUT=$(PATH="$STUB_DIR:$PATH" STUB_MODE=http500 CREWLY_SESSION_NAME=agent-1 bash "$SCRIPT_DIR/execute.sh" '{"sessionName":"agent-1"}' </dev/null 2>&1) && EXIT_CODE=$? || EXIT_CODE=$?
334
+ assert_eq "a real 500 is still an error" "1" "$EXIT_CODE"
335
+ rm -rf "$STUB_DIR"
336
+
300
337
  # ---------------------------------------------------------------------------
301
338
  # Summary
302
339
  # ---------------------------------------------------------------------------