@@ -27,17 +27,40 @@ func newSessionCmd() *cobra.Command {
2727 return cmd
2828}
2929
30- // sessionListFormats are the output shapes 'session list' supports. jsonl (one
31- // SessionItem JSON object per line) is the default because the rows feed
32- // line-oriented downstream tooling (the /insight skill streams them through jq);
33- // json emits the whole SessionListResponse envelope; toon is the compact,
34- // fewer-tokens encoding.
30+ // session commands accept these output shapes via the global --output-format
31+ // flag (and its --json alias). jsonl (one SessionItem JSON object per line) is
32+ // the default because the rows feed line-oriented downstream tooling (the
33+ // /insight skill streams them through jq); json emits the whole
34+ // SessionListResponse envelope; toon is the compact, fewer-tokens encoding.
35+ //
36+ // jsonl is NOT a value the global table|json|toon resolver accepts, so session
37+ // commands carry the ownsOutputFormat annotation and resolve the flag here via
38+ // resolveSessionFormat instead of through resolveOutputFormat.
3539const (
3640 sessionFormatJSONL = "jsonl"
3741 sessionFormatJSON = "json"
3842 sessionFormatTOON = "toon"
3943)
4044
45+ // resolveSessionFormat maps the global --output-format / --json flags to a
46+ // session output shape, defaulting to jsonl. Unlike the account-wide resolver
47+ // it accepts jsonl (and rejects table, which is meaningless for the bulk
48+ // streaming rows these commands emit). An unrecognized value errors so a typo
49+ // fails fast rather than silently falling back.
50+ func resolveSessionFormat () (string , error ) {
51+ switch f := strings .ToLower (strings .TrimSpace (flagOutputFormat )); f {
52+ case sessionFormatJSONL , sessionFormatJSON , sessionFormatTOON :
53+ return f , nil
54+ case "" :
55+ if flagJSON {
56+ return sessionFormatJSON , nil
57+ }
58+ return sessionFormatJSONL , nil
59+ default :
60+ return "" , fmt .Errorf ("invalid --output-format %q (want jsonl, json, or toon)" , flagOutputFormat )
61+ }
62+ }
63+
4164// sessionPageLimit is the largest per-page Limit the /safari/session/list
4265// handler accepts. The server validates limit with binding "lte=100": a
4366// limit > 100 is a hard 400 bind failure, NOT a clamp, so every page request
@@ -51,7 +74,6 @@ func newSessionListCmd() *cobra.Command {
5174 scope string
5275 status string
5376 since string
54- format string
5577 teamID int64
5678 limit int
5779 page int
@@ -60,6 +82,9 @@ func newSessionListCmd() *cobra.Command {
6082 cmd := & cobra.Command {
6183 Use : "list" ,
6284 Short : "List agent sessions" ,
85+ // Resolve --output-format ourselves: jsonl is the default and is not a
86+ // value the global table|json|toon resolver accepts.
87+ Annotations : map [string ]string {ownsOutputFormat : "true" },
6388 Long : curatedLong (
6489 "List agent sessions visible to the caller, newest first. Reads are scoped to the " +
6590 "person the app_key resolves to within its account.\n \n " +
@@ -68,15 +93,14 @@ func newSessionListCmd() *cobra.Command {
6893 "updated_at after fetching. --team-id restricts to one team (sets team_ids); --scope " +
6994 "chooses the visibility bucket (all = own + member-teams, the default). Output is " +
7095 "newline-delimited JSON (jsonl) by default so rows pipe straight into jq; use " +
71- "--format json for the full envelope or --format toon for the compact encoding." ,
96+ "--output-format json for the full envelope or --output-format toon for the compact " +
97+ "encoding." ,
7298 "Sessions" , "List" ),
7399 RunE : func (cmd * cobra.Command , args []string ) error {
74100 return runCommand (cmd , args , func (ctx * RunContext ) error {
75- format = strings .ToLower (strings .TrimSpace (format ))
76- switch format {
77- case sessionFormatJSONL , sessionFormatJSON , sessionFormatTOON :
78- default :
79- return fmt .Errorf ("invalid --format %q (want jsonl, json, or toon)" , format )
101+ format , err := resolveSessionFormat ()
102+ if err != nil {
103+ return err
80104 }
81105
82106 var sinceUnix int64
@@ -121,8 +145,10 @@ func newSessionListCmd() *cobra.Command {
121145 cmd .Flags ().Int64Var (& teamID , "team-id" , 0 , "Restrict to one team ID" )
122146 cmd .Flags ().IntVar (& limit , "limit" , 200 , "Max sessions to fetch; fetched across multiple 100-row server pages as needed" )
123147 cmd .Flags ().IntVar (& page , "page" , 1 , "1-based page to start paginating from" )
124- cmd .Flags ().StringVar (& format , "format" , sessionFormatJSONL , "Output format: jsonl (default), json, or toon" )
125- registerEnumFlag (cmd , "format" , sessionFormatJSONL , sessionFormatJSON , sessionFormatTOON )
148+ // --output-format is the inherited global flag; session commands accept
149+ // jsonl (default), json, or toon. Override its completion so it advertises
150+ // the session set, not the global table|json|toon.
151+ registerEnumFlag (cmd , "output-format" , sessionFormatJSONL , sessionFormatJSON , sessionFormatTOON )
126152
127153 return cmd
128154}
@@ -259,16 +285,27 @@ func buildSessionExportCmd(use string) *cobra.Command {
259285 cmd := & cobra.Command {
260286 Use : use ,
261287 Short : "Stream a session's full event transcript as NDJSON" ,
288+ // Resolve --output-format ourselves: jsonl is the default and is not a
289+ // value the global table|json|toon resolver accepts.
290+ Annotations : map [string ]string {ownsOutputFormat : "true" },
262291 Long : "Stream one session's full event transcript as newline-delimited JSON (NDJSON) to stdout.\n \n " +
263292 "The first line is always a session_meta envelope; each subsequent line is one event\n " +
264293 "(user_message, llm_call, tool_call, subagent_dispatch, final_answer, agent_text, error).\n " +
265294 "With --include-subagents, each subagent_dispatch line is followed by the child session's\n " +
266- "own stream. The transcript can be large, so redirect it to a file rather than reading it\n " +
267- "into a terminal:\n \n " +
295+ "own stream.\n \n " +
296+ "The default (jsonl) streams line-by-line so a huge transcript never lands in memory;\n " +
297+ "redirect it to a file rather than reading it into a terminal. --output-format json\n " +
298+ "buffers the whole transcript into a single JSON array and --output-format toon into the\n " +
299+ "compact encoding (both materialize the full transcript, so prefer jsonl for large ones):\n \n " +
268300 " flashduty session export <id> > session.ndjson\n " ,
269301 Args : requireArgs ("session_id" ),
270302 RunE : func (cmd * cobra.Command , args []string ) error {
271303 return runCommand (cmd , args , func (ctx * RunContext ) error {
304+ format , err := resolveSessionFormat ()
305+ if err != nil {
306+ return err
307+ }
308+
272309 rc , _ , err := ctx .Client .Sessions .Export (cmdContext (ctx .Cmd ), & flashduty.SessionExportRequest {
273310 SessionID : ctx .Args [0 ],
274311 IncludeSubagents : includeSubagents ,
@@ -278,25 +315,75 @@ func buildSessionExportCmd(use string) *cobra.Command {
278315 }
279316 defer func () { _ = rc .Close () }()
280317
281- // Stream the NDJSON straight through to the writer without
282- // buffering the whole transcript: copy line-by-line so a huge
283- // export never lands in memory or the agent's context.
284- sc := flashduty .NewExportScanner (rc )
285- for sc .Scan () {
286- if _ , err := fmt .Fprintln (ctx .Writer , sc .Text ()); err != nil {
287- return err
288- }
289- }
290- return sc .Err ()
318+ return writeSessionExport (ctx .Writer , format , rc )
291319 })
292320 },
293321 }
294322
295323 cmd .Flags ().BoolVar (& includeSubagents , "include-subagents" , false , "Inline each dispatched subagent's own event stream" )
324+ // --output-format is the inherited global flag; session export accepts
325+ // jsonl (default, streamed), json, or toon. Override its completion so it
326+ // advertises the session set, not the global table|json|toon.
327+ registerEnumFlag (cmd , "output-format" , sessionFormatJSONL , sessionFormatJSON , sessionFormatTOON )
296328
297329 return cmd
298330}
299331
332+ // writeSessionExport renders the export NDJSON stream in the requested format.
333+ // jsonl streams each line straight through without buffering, so a huge
334+ // transcript never lands in memory; json and toon necessarily materialize the
335+ // whole transcript (those encodings need every line) — json emits one indented
336+ // JSON array of the event objects, toon emits the compact encoding.
337+ func writeSessionExport (w io.Writer , format string , rc io.Reader ) error {
338+ sc := flashduty .NewExportScanner (rc )
339+
340+ if format == sessionFormatJSONL {
341+ for sc .Scan () {
342+ if _ , err := fmt .Fprintln (w , sc .Text ()); err != nil {
343+ return err
344+ }
345+ }
346+ return sc .Err ()
347+ }
348+
349+ // json/toon: collect every event line, then encode the whole array.
350+ events := make ([]json.RawMessage , 0 , 256 )
351+ for sc .Scan () {
352+ line := strings .TrimSpace (sc .Text ())
353+ if line == "" {
354+ continue
355+ }
356+ events = append (events , json .RawMessage (line ))
357+ }
358+ if err := sc .Err (); err != nil {
359+ return err
360+ }
361+
362+ var (
363+ out []byte
364+ err error
365+ )
366+ if format == sessionFormatTOON {
367+ // TOON marshals Go values, not raw JSON, so decode the events first.
368+ decoded := make ([]any , 0 , len (events ))
369+ for _ , raw := range events {
370+ var v any
371+ if err := json .Unmarshal (raw , & v ); err != nil {
372+ return fmt .Errorf ("failed to decode export event: %w" , err )
373+ }
374+ decoded = append (decoded , v )
375+ }
376+ out , err = toon .Marshal (decoded )
377+ } else {
378+ out , err = json .MarshalIndent (events , "" , " " )
379+ }
380+ if err != nil {
381+ return fmt .Errorf ("failed to marshal export: %w" , err )
382+ }
383+ _ , _ = fmt .Fprintln (w , string (out ))
384+ return nil
385+ }
386+
300387// attachSafariSessionExport adds the path-is-king `safari session-export` leaf to
301388// the generated `safari` group. It must run AFTER registerGenerated so the group
302389// exists; genGroup find-or-creates it and genAddLeaf is a no-op if a same-named
0 commit comments